//
//  STPPaymentHandler.swift
//  StripePayments
//
//  Created by Cameron Sabol on 5/10/19.
//  Copyright © 2019 Stripe, Inc. All rights reserved.
//

import AuthenticationServices
import Foundation
import PassKit
import SafariServices
@_spi(STP) import StripeCore

#if canImport(Stripe3DS2)
import Stripe3DS2
#endif

/// `STPPaymentHandlerActionStatus` represents the possible outcomes of requesting an action by `STPPaymentHandler`. An action could be confirming and/or handling the next action for a PaymentIntent.
@objc public enum STPPaymentHandlerActionStatus: Int {
    /// The action succeeded.
    case succeeded
    /// The action was cancelled by the cardholder/user.
    case canceled
    /// The action failed. See the error code for more details.
    case failed
}

/// Error codes generated by `STPPaymentHandler`
@objc public enum STPPaymentHandlerErrorCode: Int {
    /// Indicates that the action requires an authentication method not recognized or supported by the SDK.
    @objc(STPPaymentHandlerUnsupportedAuthenticationErrorCode)
    case unsupportedAuthenticationErrorCode

    /// Indicates that the action requires an authentication app, but either the app is not installed or the request to switch to the app was denied.
    @objc(STPPaymentHandlerRequiredAppNotAvailableErrorCode)
    case requiredAppNotAvailable

    /// Attach a payment method to the PaymentIntent or SetupIntent before using `STPPaymentHandler`.
    @objc(STPPaymentHandlerRequiresPaymentMethodErrorCode)
    case requiresPaymentMethodErrorCode

    /// The PaymentIntent or SetupIntent status cannot be resolved by `STPPaymentHandler`.
    @objc(STPPaymentHandlerIntentStatusErrorCode)
    case intentStatusErrorCode

    /// The action timed out.
    @objc(STPPaymentHandlerTimedOutErrorCode)
    case timedOutErrorCode

    /// There was an error in the Stripe3DS2 SDK.
    @objc(STPPaymentHandlerStripe3DS2ErrorCode)
    case stripe3DS2ErrorCode

    /// The transaction did not authenticate (e.g. user entered the wrong code).
    @objc(STPPaymentHandlerNotAuthenticatedErrorCode)
    case notAuthenticatedErrorCode

    /// `STPPaymentHandler` does not support concurrent actions.
    @objc(STPPaymentHandlerNoConcurrentActionsErrorCode)
    case noConcurrentActionsErrorCode

    /// Payment requires a valid `STPAuthenticationContext`.  Make sure your presentingViewController isn't already presenting.
    @objc(STPPaymentHandlerRequiresAuthenticationContextErrorCode)
    case requiresAuthenticationContextErrorCode

    /// There was an error confirming the Intent.
    /// Inspect the `paymentIntent.lastPaymentError` or `setupIntent.lastSetupError` property.
    @objc(STPPaymentHandlerPaymentErrorCode)
    case paymentErrorCode

    /// The provided PaymentIntent of SetupIntent client secret does not match the expected pattern for client secrets.
    /// Make sure that your server is returning the correct value and that is being passed to `STPPaymentHandler`.
    @objc(STPPaymentHandlerInvalidClientSecret)
    case invalidClientSecret

    /// The payment method requires a return URL and one was not provided. Your integration should provide one in your `STPPaymentIntentConfirmParams`/`STPSetupIntentConfirmParams` object if you call `STPPaymentHandler.confirm...` or when you call  `STPPaymentHandler.handleNextAction`.
    @objc(STPPaymentHandlerMissingReturnURL)
    case missingReturnURL

    /// The SDK encountered an unexpected error, indicating a problem with the SDK or the Stripe API.
    @objc(STPPaymentHandlerUnexpectedErrorCode)
    case unexpectedErrorCode
}

/// Completion block typedef for use in `STPPaymentHandler` methods for Payment Intents.
public typealias STPPaymentHandlerActionPaymentIntentCompletionBlock = (
    STPPaymentHandlerActionStatus, STPPaymentIntent?, NSError?
) -> Void
/// Completion block typedef for use in `STPPaymentHandler` methods for Setup Intents.
public typealias STPPaymentHandlerActionSetupIntentCompletionBlock = (
    STPPaymentHandlerActionStatus, STPSetupIntent?, NSError?
) -> Void

let missingReturnURLErrorMessage = "The payment method requires a return URL and one was not provided. Your integration should provide one in your `STPPaymentIntentConfirmParams`/`STPSetupIntentConfirmParams` object if you call `STPPaymentHandler.confirm...` or when you call  `STPPaymentHandler.handleNextAction`."

/// `STPPaymentHandler` is a utility class that confirms PaymentIntents/SetupIntents and handles any authentication required, such as 3DS1/3DS2 for Strong Customer Authentication.
/// It can present authentication UI on top of your app or redirect users out of your app (to e.g. their banking app).
/// - seealso: https://stripe.com/docs/payments/3d-secure
public class STPPaymentHandler: NSObject {
    /// The error domain for errors in `STPPaymentHandler`.
    @objc public static let errorDomain = "STPPaymentHandlerErrorDomain"

    /// These indicate a programming error in STPPaymentHandler. They are separate from the NSErrors vended to merchants; these errors are only reported to analytics and do not get vended to users of this class.
    enum InternalError: Error {
        case invalidState
    }

    internal var currentAction: STPPaymentHandlerActionParams?
    /// YES from when a public method is first called until its associated completion handler is called.
    /// This property guards against simultaneous usage of this class; only one "next action" can be handled at a time.
    private static var inProgress = false
    private var safariViewController: SFSafariViewController?
    private var asWebAuthenticationSession: ASWebAuthenticationSession?

    /// The globally shared instance of `STPPaymentHandler`.
    @objc public static let sharedHandler: STPPaymentHandler = STPPaymentHandler()

    /// The globally shared instance of `STPPaymentHandler`.
    @objc
    public class func shared() -> STPPaymentHandler {
        return STPPaymentHandler.sharedHandler
    }

    @_spi(STP) public init(
        apiClient: STPAPIClient = .shared,
        threeDSCustomizationSettings: STPThreeDSCustomizationSettings =
            STPThreeDSCustomizationSettings()
    ) {
        self.apiClient = apiClient
        self.threeDSCustomizationSettings = threeDSCustomizationSettings
        super.init()
    }

    /// By default `sharedHandler` initializes with STPAPIClient.shared.
    @objc public var apiClient: STPAPIClient

    /// Customizable settings to use when performing 3DS2 authentication.
    /// Note: Configure this before calling any methods.
    /// Defaults to `STPThreeDSCustomizationSettings()`.
    @objc public var threeDSCustomizationSettings: STPThreeDSCustomizationSettings

    internal var _simulateAppToAppRedirect: Bool = false

    /// When this flag is enabled, STPPaymentHandler will confirm certain PaymentMethods using
    /// Safari instead of SFSafariViewController. If you'd like to use this in your own
    /// testing or Continuous Integration platform, please see the IntegrationTester app
    /// for usage examples.
    ///
    /// Note: This flag is only intended for development, and only impacts payments made with testmode keys.
    /// Setting this to `true` with a livemode key will fail.
    @objc public var simulateAppToAppRedirect: Bool
    {
        get {
            _simulateAppToAppRedirect && STPAPIClient.shared.isTestmode
        }
        set {
            _simulateAppToAppRedirect = newValue
        }
    }

    internal var _redirectShim: ((URL, URL?, Bool) -> Void)?
    internal var isInProgress: Bool {
        return STPPaymentHandler.inProgress
    }
    internal var analyticsClient: STPAnalyticsClient = .sharedClient
    /// Date at which `confirm` or `handleNextAction` is called. Used to report how long the call took.
    internal var startTime: Date? {
        didSet {
            actionID = startTime == nil ? nil : UUID().uuidString
        }
    }
    /// A uuid unique to a given `confirm` or `handleNextAction` call, so that we can group together all the analytics sent during a confirmation / next action handling.
    /// Note that Session ID is not good enough, since a single session may have multiple calls to confirm.
    internal var actionID: String?

    // MARK: - PaymentIntent APIs
    /// Confirms the PaymentIntent using the provided `params` and handles any next actions required to authenticate the PaymentIntent.
    /// - Parameters:
    ///   - params: The params used to confirm the PaymentIntent. Note that this method overrides the value of `paymentParams.useStripeSDK` to `@YES`.
    ///   - authenticationContext: The authentication context used to authenticate the payment.
    ///   - completion: The completion block.
    /// - Note: If the status returned is `STPPaymentHandlerActionStatus.succeeded`, the PaymentIntent status is not necessarily `STPPaymentIntentStatus.succeeded` (e.g. some bank payment methods take days before money moves and the PaymentIntent succeeds).
    @objc(confirmPaymentIntentWithParams:authenticationContext:completion:)
    public func confirmPaymentIntent(
        params: STPPaymentIntentConfirmParams,
        authenticationContext: STPAuthenticationContext,
        completion: @escaping STPPaymentHandlerActionPaymentIntentCompletionBlock
    ) {
        let paymentParams = params
        let paymentIntentID = paymentParams.stripeId
        logConfirmPaymentIntentStarted(paymentIntentID: paymentIntentID, paymentParams: paymentParams)
        // Overwrite completion to send an analytic before calling the caller-supplied completion
        let completion: STPPaymentHandlerActionPaymentIntentCompletionBlock = { [weak self] status, paymentIntent, error in
            self?.logConfirmPaymentIntentCompleted(paymentIntentID: paymentIntentID, paymentParams: paymentParams, status: status, error: error)
            completion(status, paymentIntent, error)
        }
        if Self.inProgress {
            assertionFailure("`STPPaymentHandler.confirmPayment` was called while a previous call is still in progress.")
            completion(.failed, nil, _error(for: .noConcurrentActionsErrorCode))
            return
        } else if !STPPaymentIntentConfirmParams.isClientSecretValid(paymentParams.clientSecret) {
            assertionFailure("`STPPaymentHandler.confirmPayment` was called with an invalid client secret. See https://docs.stripe.com/api/payment_intents/object#payment_intent_object-client_secret")
            completion(.failed, nil, _error(for: .invalidClientSecret))
            return
        }
        Self.inProgress = true
        weak var weakSelf = self
        // wrappedCompletion ensures we perform some final logic before calling the completion block.
        let wrappedCompletion: STPPaymentHandlerActionPaymentIntentCompletionBlock = {
            status,
            paymentIntent,
            error in
            guard let strongSelf = weakSelf else {
                return
            }
            // Reset our internal state
            Self.inProgress = false

            // Ensure the .succeeded case returns a PaymentIntent in the expected state.
            if let paymentIntent = paymentIntent, status == .succeeded {
                let successIntentState =
                    paymentIntent.status == .succeeded || paymentIntent.status == .requiresCapture
                    || (paymentIntent.status == .processing
                        && STPPaymentHandler._isProcessingIntentSuccess(
                            for: paymentIntent.paymentMethod?.type ?? .unknown
                        )
                        || (paymentIntent.status == .requiresAction
                            && strongSelf.isNextActionSuccessState(
                                nextAction: paymentIntent.nextAction
                            )))

                if error == nil && successIntentState {
                    completion(.succeeded, paymentIntent, nil)
                } else {
                    let errorMessage = "STPPaymentHandler status is succeeded, but the PI is not in a success state or there was an error."
                    stpAssertionFailure(errorMessage)
                    let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: [
                        "error_message": errorMessage,
                        "payment_intent": paymentIntent.stripeId,
                        "payment_intent_status": STPPaymentIntentStatus.string(from: paymentIntent.status),
                        "error_details": error?.serializeForV1Analytics() ?? [:],
                    ])
                    strongSelf.analyticsClient.log(analytic: errorAnalytic, apiClient: strongSelf.apiClient)
                    completion(
                        .failed,
                        paymentIntent,
                        error ?? strongSelf._error(for: .intentStatusErrorCode)
                    )
                }
                return
            }
            completion(status, paymentIntent, error)
        }

        let confirmCompletionBlock: STPPaymentIntentCompletionBlock = { paymentIntent, error in
            guard let strongSelf = weakSelf else {
                assertionFailure("STPPaymentHandler became nil during `confirmPayment`!")
                wrappedCompletion(.failed, nil, nil)
                return
            }
            if let paymentIntent = paymentIntent,
                error == nil
            {
                strongSelf._handleNextAction(
                    forPayment: paymentIntent,
                    with: authenticationContext,
                    returnURL: paymentParams.returnURL
                ) { status, completedPaymentIntent, completedError in
                    wrappedCompletion(status, completedPaymentIntent, completedError)
                }
            } else {
                wrappedCompletion(.failed, paymentIntent, error as NSError?)
            }
        }

        var params = paymentParams
        // We always set useStripeSDK = @YES in STPPaymentHandler
        if !(params.useStripeSDK ?? false) {
            params = paymentParams.copy() as! STPPaymentIntentConfirmParams
            params.useStripeSDK = true
        }
        apiClient.confirmPaymentIntent(
            with: params,
            expand: ["payment_method"],
            completion: confirmCompletionBlock
        )
    }

    /// Confirms the PaymentIntent using the provided `params` and handles any next actions required to authenticate the PaymentIntent.
    /// - Parameters:
    ///   - params: The params used to confirm the PaymentIntent. Note that this method overrides the value of `paymentParams.useStripeSDK` to `@YES`.
    ///   - authenticationContext: The authentication context used to authenticate the payment.
    /// - Note: If the status returned is `STPPaymentHandlerActionStatus.succeeded`, the PaymentIntent status is not necessarily `STPPaymentIntentStatus.succeeded` (e.g. some bank payment methods take days before money moves and the PaymentIntent succeeds).
    public func confirmPaymentIntent(
        params: STPPaymentIntentConfirmParams,
        authenticationContext: STPAuthenticationContext
    ) async -> (STPPaymentHandlerActionStatus, STPPaymentIntent?, Error?) {
        return await withCheckedContinuation { continuation in
            confirmPaymentIntent(params: params, authenticationContext: authenticationContext) { status, paymentIntent, error in
                continuation.resume(returning: (status, paymentIntent, error))
            }
        }
    }

    @_spi(SharedPaymentToken) public func handleNextAction(
        forPaymentHashedValue hashedValue: String,
        with authenticationContext: STPAuthenticationContext,
        returnURL: String?,
        completion: @escaping STPPaymentHandlerActionPaymentIntentCompletionBlock
    ) {
        guard subhandler == nil else {
            stpAssertionFailure("`STPPaymentHandler.handleNextAction(forPaymentHashedValue:with:completion:)` was called while a previous call is still in progress.")
            completion(.failed, nil, _error(for: .noConcurrentActionsErrorCode))
            return
        }
        // hashedValue is a base64 encoded string in "pk_test_123:pi_123_secret_abc" format

        // Strip out any newlines or "\n" before decoding
        let hashedValue = hashedValue.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(
            of: "\n",
            with: ""
        )

        guard let decodedData = Data(base64Encoded: hashedValue),
              let decodedString = String(data: decodedData, encoding: .utf8) else {
            completion(.failed, nil, _error(for: .invalidClientSecret))
            return
        }

        // Parse the decoded string to extract publishable key and client secret
        let components = decodedString.components(separatedBy: ":")
        guard components.count >= 2 else {
            completion(.failed, nil, _error(for: .invalidClientSecret))
            return
        }

        let publishableKey = components[0]
        let clientSecret = components[1..<components.count].joined(separator: ":")

        // Create a new API client with the publishable key
        let apiClient = STPAPIClient(publishableKey: publishableKey)

        // Create a new payment handler with the new API client
        let subhandler = STPPaymentHandler(apiClient: apiClient, threeDSCustomizationSettings: self.threeDSCustomizationSettings)

        // Use the new handler to handle the next action
        subhandler.handleNextAction(
            paymentIntentClientSecret: clientSecret,
            authenticationContext: authenticationContext,
            returnURL: returnURL,
            completion: { action, paymentIntent, error in
                completion(action, paymentIntent, error)
                // Clean up the subhandler
                self.subhandler = nil
            }
        )
        // Retain the subhandler during the confirmation
        self.subhandler = subhandler
    }
    private var subhandler: STPPaymentHandler?

    /// Handles any `nextAction` required to authenticate the PaymentIntent.
    /// Call this method if you are using server-side confirmation.
    /// - Parameters:
    ///   - paymentIntentClientSecret: The client secret of the PaymentIntent to handle next actions for.
    ///   - authenticationContext: The authentication context used to authenticate the payment.
    ///   - returnURL: An optional URL to redirect your customer back to after they authenticate or cancel in a webview. This should match the returnURL you specified during PaymentIntent confirmation.
    /// - Returns:
    public func handleNextAction(
        paymentIntentClientSecret: String,
        authenticationContext: STPAuthenticationContext,
        returnURL: String?
    ) async -> (STPPaymentHandlerActionStatus, STPPaymentIntent?, NSError?) {
        return await withCheckedContinuation { continuation in
            handleNextAction(paymentIntentClientSecret: paymentIntentClientSecret, authenticationContext: authenticationContext, returnURL: returnURL) { status, paymentIntent, error in
                continuation.resume(returning: (status, paymentIntent, error))
            }
        }
    }

    /// Handles any `nextAction` required to authenticate the PaymentIntent.
    /// Call this method if you are using server-side confirmation.
    /// - Parameters:
    ///   - paymentIntentClientSecret: The client secret of the PaymentIntent to handle next actions for.
    ///   - authenticationContext: The authentication context used to authenticate the payment.
    ///   - returnURL: An optional URL to redirect your customer back to after they authenticate or cancel in a webview. This should match the returnURL you specified during PaymentIntent confirmation.
    ///   - completion: The completion block. If the status returned is `STPPaymentHandlerActionStatusSucceeded`, the PaymentIntent status is not necessarily STPPaymentIntentStatusSucceeded (e.g. some bank payment methods take days before the PaymentIntent succeeds).
    @objc(handleNextActionForPaymentIntent:authenticationContext:returnURL:completion:)
    public func handleNextAction(
        paymentIntentClientSecret: String,
        authenticationContext: STPAuthenticationContext,
        returnURL: String?,
        completion: @escaping STPPaymentHandlerActionPaymentIntentCompletionBlock
    ) {
        let paymentIntentID = STPPaymentIntent.id(fromClientSecret: paymentIntentClientSecret)
        // Overwrite completion to send an analytic before calling the caller-supplied completion
        let completion: STPPaymentHandlerActionPaymentIntentCompletionBlock = { [weak self] status, paymentIntent, error in
            self?.logHandleNextActionFinished(intentID: paymentIntentID, paymentMethod: paymentIntent?.paymentMethod, status: status, error: error)
            completion(status, paymentIntent, error)
        }
        logHandleNextActionStarted(intentID: paymentIntentID, paymentMethod: nil)
        if !STPPaymentIntentConfirmParams.isClientSecretValid(paymentIntentClientSecret) {
            assertionFailure("`STPPaymentHandler.handleNextAction` was called with an invalid client secret. See https://docs.stripe.com/api/payment_intents/object#payment_intent_object-client_secret")
            completion(.failed, nil, _error(for: .invalidClientSecret))
            return
        }
        apiClient.retrievePaymentIntent(
            withClientSecret: paymentIntentClientSecret,
            expand: ["payment_method"]
        ) { [weak self] paymentIntent, error in
            guard let self else {
                return
            }
            if let paymentIntent = paymentIntent, error == nil {
                self.handleNextAction(for: paymentIntent, with: authenticationContext, returnURL: returnURL, shouldSendAnalytic: false, completion: completion)
            } else {
                completion(.failed, paymentIntent, error as NSError?)
            }
        }
    }

    @_spi(STP) public func handleNextAction(
        for paymentIntent: STPPaymentIntent,
        with authenticationContext: STPAuthenticationContext,
        returnURL: String?,
        shouldSendAnalytic: Bool = true,
        completion: @escaping STPPaymentHandlerActionPaymentIntentCompletionBlock
    ) {
        let paymentIntentID = paymentIntent.stripeId
        let paymentMethod = paymentIntent.paymentMethod
        if shouldSendAnalytic {
            logHandleNextActionStarted(intentID: paymentIntentID, paymentMethod: paymentMethod)
        }
        // Overwrite completion to send an analytic before calling the caller-supplied completion
        let completion: STPPaymentHandlerActionPaymentIntentCompletionBlock = { [weak self] status, paymentIntent, error in
            if shouldSendAnalytic {
                self?.logHandleNextActionFinished(intentID: paymentIntentID, paymentMethod: paymentMethod, status: status, error: error)
            }
            completion(status, paymentIntent, error)
        }
        if Self.inProgress {
            assertionFailure("`STPPaymentHandler.handleNextAction` was called while a previous call is still in progress.")
            completion(.failed, nil, _error(for: .noConcurrentActionsErrorCode))
            return
        }
        if paymentIntent.paymentMethodId != nil {
            assert(paymentIntent.paymentMethod != nil, "A PaymentIntent w/ attached paymentMethod must be retrieved w/ an expanded PaymentMethod")
        }
        Self.inProgress = true

        weak var weakSelf = self
        // wrappedCompletion ensures we perform some final logic before calling the completion block.
        let wrappedCompletion: STPPaymentHandlerActionPaymentIntentCompletionBlock = {
            status,
            paymentIntent,
            error in
            guard let strongSelf = weakSelf else {
                return
            }
            // Reset our internal state
            Self.inProgress = false
            // Ensure the .succeeded case returns a PaymentIntent in the expected state.
            if let paymentIntent = paymentIntent,
               status == .succeeded
            {
                let successIntentState =
                paymentIntent.status == .succeeded || paymentIntent.status == .requiresCapture || paymentIntent.status == .requiresConfirmation
                || (paymentIntent.status == .processing && STPPaymentHandler._isProcessingIntentSuccess(for: paymentIntent.paymentMethod?.type ?? .unknown))
                || (paymentIntent.status == .requiresAction && strongSelf.isNextActionSuccessState(nextAction: paymentIntent.nextAction))

                if error == nil && successIntentState {
                    completion(.succeeded, paymentIntent, nil)
                } else {
                    let errorMessage = "STPPaymentHandler status is succeeded, but the PI is not in a success state or there was an error."
                    stpAssertionFailure(errorMessage)
                    let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: [
                        "error_message": errorMessage,
                        "payment_intent": paymentIntent.stripeId,
                        "payment_intent_status": STPPaymentIntentStatus.string(from: paymentIntent.status),
                        "error_details": error?.serializeForV1Analytics() ?? [:],
                    ])
                    strongSelf.analyticsClient.log(analytic: errorAnalytic, apiClient: strongSelf.apiClient)
                    completion(
                        .failed,
                        paymentIntent,
                        error ?? strongSelf._error(for: .intentStatusErrorCode)
                    )
                }
                return
            }
            completion(status, paymentIntent, error)
        }

        if paymentIntent.status == .requiresConfirmation {
            // The caller forgot to confirm the paymentIntent on the backend before calling this method
            wrappedCompletion(
                .failed,
                paymentIntent,
                _error(
                    for: .intentStatusErrorCode,
                    loggingSafeErrorMessage: "Confirm the PaymentIntent on the backend before calling handleNextActionForPayment:withAuthenticationContext:completion."
                )
            )
        } else {
            _handleNextAction(
                forPayment: paymentIntent,
                with: authenticationContext,
                returnURL: returnURL
            ) { status, completedPaymentIntent, completedError in
                wrappedCompletion(status, completedPaymentIntent, completedError)
            }
        }
    }

    // MARK: - SetupIntent APIs
    /// Confirms the SetupIntent using the provided parameters and handles any next actions required to authenticate the SetupIntent.
    /// - Parameters:
    ///   - params: The params used to confirm the SetupIntent. Note that this method overrides the value of `setupIntentConfirmParams.useStripeSDK` to `@YES`.
    ///   - authenticationContext: The authentication context used to authenticate the SetupIntent.
    ///   - completion: The completion block called when this method is finished.
    /// - Note: If the status returned is `STPPaymentHandlerActionStatus.succeeded`, the SetupIntent status will always be `STPSetupIntentStatus.succeeded`.
    @objc(confirmSetupIntentWithParams:authenticationContext:completion:)
    public func confirmSetupIntent(
        params: STPSetupIntentConfirmParams,
        authenticationContext: STPAuthenticationContext,
        completion: @escaping STPPaymentHandlerActionSetupIntentCompletionBlock
    ) {
        let setupIntentConfirmParams = params
        let setupIntentID = STPSetupIntent.id(fromClientSecret: setupIntentConfirmParams.clientSecret)
        logConfirmSetupIntentStarted(setupIntentID: setupIntentID, confirmParams: setupIntentConfirmParams)
        // Overwrite completion to send an analytic before calling the caller-supplied completion
        let completion: STPPaymentHandlerActionSetupIntentCompletionBlock = { [weak self] status, setupIntent, error in
            self?.logConfirmSetupIntentCompleted(setupIntentID: setupIntentID, confirmParams: setupIntentConfirmParams, status: status, error: error)
            completion(status, setupIntent, error)
        }

        if Self.inProgress {
            assertionFailure("`STPPaymentHandler.confirmSetupIntent` was called while a previous call is still in progress.")
            completion(.failed, nil, _error(for: .noConcurrentActionsErrorCode))
            return
        } else if !STPSetupIntentConfirmParams.isClientSecretValid(
            setupIntentConfirmParams.clientSecret
        ) {
            assertionFailure("`STPPaymentHandler.confirmSetupIntent` was called with an invalid client secret. See https://docs.stripe.com/api/payment_intents/object#setup_intent_object-client_secret")
            completion(.failed, nil, _error(for: .invalidClientSecret))
            return
        }

        Self.inProgress = true
        weak var weakSelf = self
        // wrappedCompletion ensures we perform some final logic before calling the completion block.
        let wrappedCompletion: STPPaymentHandlerActionSetupIntentCompletionBlock = {
            status,
            setupIntent,
            error in
            guard let strongSelf = weakSelf else {
                return
            }
            // Reset our internal state
            Self.inProgress = false

            if status == .succeeded {
                // Ensure the .succeeded case returns a SetupIntent in the expected state.
                if let setupIntent = setupIntent,
                    error == nil,
                    setupIntent.status == .succeeded
                        || (setupIntent.status == .requiresAction
                            && self.isNextActionSuccessState(nextAction: setupIntent.nextAction))
                {
                    completion(.succeeded, setupIntent, nil)
                } else {
                    let errorMessage = "STPPaymentHandler status is succeeded, but the SI is not in a success state or there was an error."
                    stpAssertionFailure(errorMessage)
                    let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: [
                        "error_message": errorMessage,
                        "setup_intent": setupIntent?.stripeID ?? "nil",
                        "setup_intent_status": setupIntent?.status.rawValue ?? "nil",
                        "error_details": error?.serializeForV1Analytics() ?? [:],
                    ])
                    strongSelf.analyticsClient.log(analytic: errorAnalytic, apiClient: strongSelf.apiClient)
                    completion(
                        .failed,
                        setupIntent,
                        error ?? strongSelf._error(for: .intentStatusErrorCode)
                    )
                }

            } else {
                completion(status, setupIntent, error)
            }
        }

        let confirmCompletionBlock: STPSetupIntentCompletionBlock = { setupIntent, error in
            guard let strongSelf = weakSelf else {
                return
            }

            if let setupIntent = setupIntent,
                error == nil
            {
                let action = STPPaymentHandlerSetupIntentActionParams(
                    apiClient: self.apiClient,
                    authenticationContext: authenticationContext,
                    threeDSCustomizationSettings: self.threeDSCustomizationSettings,
                    setupIntent: setupIntent,
                    returnURL: setupIntentConfirmParams.returnURL
                ) { status, resultSetupIntent, resultError in
                    guard let strongSelf2 = weakSelf else {
                        return
                    }
                    strongSelf2.currentAction = nil

                    wrappedCompletion(status, resultSetupIntent, resultError)
                }
                strongSelf.currentAction = action
                let requiresAction = strongSelf._handleSetupIntentStatus(forAction: action)
                if requiresAction {
                    strongSelf._handleAuthenticationForCurrentAction()
                }
            } else {
                wrappedCompletion(.failed, setupIntent, error as NSError?)
            }
        }
        var params = setupIntentConfirmParams
        if !(params.useStripeSDK ?? false) {
            params = setupIntentConfirmParams.copy() as! STPSetupIntentConfirmParams
            params.useStripeSDK = true
        }
        apiClient.confirmSetupIntent(with: params, expand: ["payment_method"], completion: confirmCompletionBlock)
    }

    /// Confirms the SetupIntent using the provided parameters and handles any next actions required to authenticate the SetupIntent.
    /// - Parameters:
    ///   - params: The params used to confirm the SetupIntent. Note that this method overrides the value of `setupIntentConfirmParams.useStripeSDK` to `@YES`.
    ///   - authenticationContext: The authentication context used to authenticate the SetupIntent.
    /// - Note: If the status returned is `STPPaymentHandlerActionStatus.succeeded`, the SetupIntent status will always be `STPSetupIntentStatus.succeeded`.
    public func confirmSetupIntent(
        params: STPSetupIntentConfirmParams,
        authenticationContext: STPAuthenticationContext
    ) async -> (STPPaymentHandlerActionStatus, STPSetupIntent?, Error?) {
        return await withCheckedContinuation { continuation in
            confirmSetupIntent(params: params, authenticationContext: authenticationContext) { status, setupIntent, error in
                continuation.resume(returning: (status, setupIntent, error))
            }
        }
    }

    /// Handles any `nextAction` required to authenticate the SetupIntent.
    /// Call this method if you are confirming the SetupIntent on your backend and get a status of requires_action.
    /// - Parameters:
    ///   - setupIntentClientSecret: The client secret of the SetupIntent to handle next actions for.
    ///   - authenticationContext: The authentication context used to authenticate the SetupIntent.
    ///   - returnURL: An optional URL to redirect your customer back to after they authenticate or cancel in a webview. This should match the returnURL you specified during SetupIntent confirmation.
    public func handleNextAction(
        setupIntentClientSecret: String,
        authenticationContext: STPAuthenticationContext,
        returnURL: String?
    ) async -> (STPPaymentHandlerActionStatus, STPSetupIntent?, Error?) {
        return await withCheckedContinuation { continuation in
            handleNextAction(setupIntentClientSecret: setupIntentClientSecret, authenticationContext: authenticationContext, returnURL: returnURL) { status, setupIntent, error in
                continuation.resume(returning: (status, setupIntent, error))
            }
        }
    }

    /// Handles any `nextAction` required to authenticate the SetupIntent.
    /// Call this method if you are confirming the SetupIntent on your backend and get a status of requires_action.
    /// - Parameters:
    ///   - setupIntentClientSecret: The client secret of the SetupIntent to handle next actions for.
    ///   - authenticationContext: The authentication context used to authenticate the SetupIntent.
    ///   - returnURL: An optional URL to redirect your customer back to after they authenticate or cancel in a webview. This should match the returnURL you specified during SetupIntent confirmation.
    ///   - completion: The completion block. If the status returned is `STPPaymentHandlerActionStatusSucceeded`, the SetupIntent status will always be  STPSetupIntentStatusSucceeded.
    @objc(handleNextActionForSetupIntent:authenticationContext:returnURL:completion:)
    public func handleNextAction(
        setupIntentClientSecret: String,
        authenticationContext: STPAuthenticationContext,
        returnURL: String?,
        completion: @escaping STPPaymentHandlerActionSetupIntentCompletionBlock
    ) {
        let setupIntentID = STPSetupIntent.id(fromClientSecret: setupIntentClientSecret)
        // Overwrite completion to send an analytic before calling the caller-supplied completion
        let completion: STPPaymentHandlerActionSetupIntentCompletionBlock = { [weak self] status, setupIntent, error in
            self?.logHandleNextActionFinished(intentID: setupIntentID, paymentMethod: setupIntent?.paymentMethod, status: status, error: error)
            completion(status, setupIntent, error)
        }
        logHandleNextActionStarted(intentID: setupIntentID, paymentMethod: nil)

        if !STPSetupIntentConfirmParams.isClientSecretValid(setupIntentClientSecret) {
            assertionFailure("`STPPaymentHandler.handleNextAction` was called with an invalid client secret. See https://docs.stripe.com/api/payment_intents/object#setup_intent_object-client_secret")
            completion(.failed, nil, _error(for: .invalidClientSecret))
            return
        }

        apiClient.retrieveSetupIntent(withClientSecret: setupIntentClientSecret, expand: ["payment_method"]) { [weak self] setupIntent, error in
            guard let self else {
                return
            }
            if let setupIntent, error == nil {
                self.handleNextAction(for: setupIntent, with: authenticationContext, returnURL: returnURL, shouldSendAnalytic: false, completion: completion)
            } else {
                completion(.failed, setupIntent, error as NSError?)
            }
        }
    }

    /// Handles any `nextAction` required to authenticate the SetupIntent.
    /// Call this method if you are confirming the SetupIntent on your backend and get a status of requires_action.
    /// - Parameters:
    ///   - setupIntent: The SetupIntent to handle next actions for.
    ///   - authenticationContext: The authentication context used to authenticate the SetupIntent.
    ///   - returnURL: An optional URL to redirect your customer back to after they authenticate or cancel in a webview. This should match the returnURL you specified during SetupIntent confirmation.
    ///   - shouldSendStartAnalytic: Tracks whether STPPaymentHandler has already sent a start analytic for `handleNextAction` because this method was called from the other `handleNextAction`
    ///   - completion: The completion block. If the status returned is `STPPaymentHandlerActionStatusSucceeded`, the SetupIntent status will always be  STPSetupIntentStatusSucceeded.
    /// - Note: The SetupIntent must have been fetched with an expanded paymentMethod object (see how `handleNextAction(forPayment:)` does this).
    @_spi(STP) public func handleNextAction(
        for setupIntent: STPSetupIntent,
        with authenticationContext: STPAuthenticationContext,
        returnURL: String?,
        shouldSendAnalytic: Bool = true,
        completion: @escaping STPPaymentHandlerActionSetupIntentCompletionBlock
    ) {
        let setupIntentID = setupIntent.stripeID
        let paymentMethod = setupIntent.paymentMethod
        if shouldSendAnalytic {
            logHandleNextActionStarted(intentID: setupIntentID, paymentMethod: paymentMethod)
        }
        // Overwrite completion to send an analytic before calling the caller-supplied completion
        let completion: STPPaymentHandlerActionSetupIntentCompletionBlock = { [weak self] status, setupIntent, error in
            if shouldSendAnalytic {
                self?.logHandleNextActionFinished(intentID: setupIntentID, paymentMethod: paymentMethod, status: status, error: error)
            }
            completion(status, setupIntent, error)
        }
        if Self.inProgress {
            assertionFailure("`STPPaymentHandler.confirmPayment` was called while a previous call is still in progress.")
            completion(.failed, nil, _error(for: .noConcurrentActionsErrorCode))
            return
        }
        if setupIntent.paymentMethodID != nil {
            assert(setupIntent.paymentMethod != nil, "A SetupIntent w/ attached paymentMethod must be retrieved w/ an expanded PaymentMethod")
        }

        Self.inProgress = true
        weak var weakSelf = self
        // wrappedCompletion ensures we perform some final logic before calling the completion block.
        let wrappedCompletion: STPPaymentHandlerActionSetupIntentCompletionBlock = {
            status,
            setupIntent,
            error in
            guard let strongSelf = weakSelf else {
                return
            }
            // Reset our internal state
            Self.inProgress = false

            if status == .succeeded {
                // Ensure the .succeeded case returns a PaymentIntent in the expected state.
                if let setupIntent = setupIntent,
                   error == nil,
                   setupIntent.status == .succeeded
                {
                    completion(.succeeded, setupIntent, nil)
                } else {
                    let errorMessage = "STPPaymentHandler status is succeeded, but the SI is not in a success state or there was an error."
                    stpAssertionFailure(errorMessage)
                    let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: [
                        "error_message": errorMessage,
                        "setup_intent": setupIntent?.stripeID ?? "nil",
                        "setup_intent_status": setupIntent?.status.rawValue ?? "nil",
                        "error_details": error?.serializeForV1Analytics() ?? [:],
                    ])
                    strongSelf.analyticsClient.log(analytic: errorAnalytic, apiClient: strongSelf.apiClient)
                    completion(
                        .failed,
                        setupIntent,
                        error ?? strongSelf._error(for: .intentStatusErrorCode)
                    )
                }

            } else {
                completion(status, setupIntent, error)
            }
        }

        if setupIntent.status == .requiresConfirmation {
            // The caller forgot to confirm the setupIntent on the backend before calling this method
            wrappedCompletion(.failed, setupIntent, _error(for: .intentStatusErrorCode, loggingSafeErrorMessage: "Confirm the SetupIntent on the backend before calling handleNextActionForSetupIntent:withAuthenticationContext:completion.")
            )
        } else {
            _handleNextAction(
                for: setupIntent,
                with: authenticationContext,
                returnURL: returnURL
            ) { status, completedSetupIntent, completedError in
                wrappedCompletion(status, completedSetupIntent, completedError)
            }
        }
    }

    // MARK: - Private Helpers

    /// Depending on the PaymentMethod Type, after handling next action and confirming,
    /// we should either expect a success state on the PaymentIntent, or for certain asynchronous
    /// PaymentMethods like SEPA Debit, processing is considered a completed PaymentIntent flow
    /// because the funds can take up to 14 days to transfer from the customer's bank.
    class func _isProcessingIntentSuccess(for type: STPPaymentMethodType) -> Bool {
        switch type {
        // Asynchronous payment methods whose intent.status is 'processing' after handling the next action
        case .SEPADebit,
            .bacsDebit,  // Bacs Debit takes 2-3 business days
            .AUBECSDebit,
            .USBankAccount:
            return true

        // Synchronous
        case .alipay,
            .card,
            .UPI,
            .iDEAL,
            .FPX,
            .cardPresent,
            .EPS,
            .payPal,
            .przelewy24,
            .bancontact,
            .netBanking,
            .OXXO,
            .grabPay,
            .afterpayClearpay,
            .blik,
            .weChatPay,
            .boleto,
            .link,
            .klarna,
            .affirm,
            .cashApp,
            .paynow,
            .zip,
            .revolutPay,
            .mobilePay,
            .amazonPay,
            .alma,
            .sunbit,
            .billie,
            .satispay,
            .crypto,
            .konbini,
            .promptPay,
            .swish,
            .twint,
            .multibanco,
            .shopPay:
            return false

        case .unknown:
            return false

        @unknown default:
            return false
        }
    }

    func _handleNextAction(
        forPayment paymentIntent: STPPaymentIntent,
        with authenticationContext: STPAuthenticationContext,
        returnURL returnURLString: String?,
        completion: @escaping STPPaymentHandlerActionPaymentIntentCompletionBlock
    ) {
        guard paymentIntent.status != .requiresPaymentMethod else {
            // The caller forgot to attach a paymentMethod.
            completion(
                .failed,
                paymentIntent,
                _error(for: .requiresPaymentMethodErrorCode)
            )
            return
        }

        weak var weakSelf = self
        let action = STPPaymentHandlerPaymentIntentActionParams(
            apiClient: apiClient,
            authenticationContext: authenticationContext,
            threeDSCustomizationSettings: threeDSCustomizationSettings,
            paymentIntent: paymentIntent,
            returnURL: returnURLString
        ) { status, resultPaymentIntent, error in
            guard let strongSelf = weakSelf else {
                return
            }
            strongSelf.currentAction = nil
            completion(status, resultPaymentIntent, error)
        }
        currentAction = action
        let requiresAction = _handlePaymentIntentStatus(forAction: action)
        if requiresAction {
            _handleAuthenticationForCurrentAction()
        }
    }

    func _handleNextAction(
        for setupIntent: STPSetupIntent,
        with authenticationContext: STPAuthenticationContext,
        returnURL returnURLString: String?,
        completion: @escaping STPPaymentHandlerActionSetupIntentCompletionBlock
    ) {
        guard setupIntent.status != .requiresPaymentMethod else {
            // The caller forgot to attach a paymentMethod.
            completion(
                .failed,
                setupIntent,
                _error(for: .requiresPaymentMethodErrorCode)
            )
            return
        }

        weak var weakSelf = self
        let action = STPPaymentHandlerSetupIntentActionParams(
            apiClient: apiClient,
            authenticationContext: authenticationContext,
            threeDSCustomizationSettings: threeDSCustomizationSettings,
            setupIntent: setupIntent,
            returnURL: returnURLString
        ) { status, resultSetupIntent, resultError in
            guard let strongSelf = weakSelf else {
                return
            }
            strongSelf.currentAction = nil
            completion(status, resultSetupIntent, resultError)
        }
        currentAction = action
        let requiresAction = _handleSetupIntentStatus(forAction: action)
        if requiresAction {
            _handleAuthenticationForCurrentAction()
        }
    }

    /// Calls the current action's completion handler for the SetupIntent status, or returns YES if the status is ...RequiresAction.
    func _handleSetupIntentStatus(
        forAction action: STPPaymentHandlerSetupIntentActionParams
    )
        -> Bool
    {
        let setupIntent = action.setupIntent
        switch setupIntent.status {
        case .unknown:
            action.complete(
                with: STPPaymentHandlerActionStatus.failed,
                error: _error(
                    for: .unexpectedErrorCode,
                    loggingSafeErrorMessage: "Unknown SetupIntent status"
                )
            )

        case .requiresPaymentMethod:
            // If the user forgot to attach a PaymentMethod, they get an error before this point.
            // If confirmation fails (eg not authenticated, card declined) the SetupIntent transitions to this state.
            if let lastSetupError = setupIntent.lastSetupError {
                if lastSetupError.code == STPSetupIntentLastSetupError.CodeAuthenticationFailure {
                    action.complete(
                        with: STPPaymentHandlerActionStatus.failed,
                        error: _error(for: .notAuthenticatedErrorCode)
                    )
                } else if lastSetupError.type == .card {
                    action.complete(
                        with: STPPaymentHandlerActionStatus.failed,
                        error: _error(
                            for: .paymentErrorCode,
                            apiErrorCode: lastSetupError.code,
                            localizedDescription: lastSetupError.message
                        )
                    )
                } else {
                    action.complete(
                        with: STPPaymentHandlerActionStatus.failed,
                        error: _error(for: .paymentErrorCode, apiErrorCode: lastSetupError.code)
                    )
                }
            } else {
                action.complete(
                    with: STPPaymentHandlerActionStatus.failed,
                    error: _error(for: .paymentErrorCode)
                )
            }

        case .requiresConfirmation:
            action.complete(with: STPPaymentHandlerActionStatus.succeeded, error: nil)

        case .requiresAction:
            return true

        case .processing:
            action.complete(
                with: STPPaymentHandlerActionStatus.failed,
                error: _error(for: .intentStatusErrorCode)
            )

        case .succeeded:
            action.complete(with: STPPaymentHandlerActionStatus.succeeded, error: nil)

        case .canceled:
            action.complete(with: STPPaymentHandlerActionStatus.canceled, error: nil)
        }
        return false
    }

    /// Calls the current action's completion handler for the PaymentIntent status, or returns YES if the status is ...RequiresAction.
    func _handlePaymentIntentStatus(
        forAction action: STPPaymentHandlerPaymentIntentActionParams
    )
        -> Bool
    {
        let paymentIntent = action.paymentIntent
        switch paymentIntent.status {
        case .unknown:
            action.complete(
                with: STPPaymentHandlerActionStatus.failed,
                error: _error(
                    for: .unexpectedErrorCode,
                    loggingSafeErrorMessage: "Unknown PaymentIntent status"
                )
            )

        case .requiresPaymentMethod:
            // If the user forgot to attach a PaymentMethod, they get an error before this point.
            // If confirmation fails (eg not authenticated, card declined) the PaymentIntent transitions to this state.
            if let lastPaymentError = paymentIntent.lastPaymentError {
                if lastPaymentError.code
                    == STPPaymentIntentLastPaymentError.ErrorCodeAuthenticationFailure
                {
                    action.complete(
                        with: STPPaymentHandlerActionStatus.failed,
                        error: _error(for: .notAuthenticatedErrorCode)
                    )
                } else if lastPaymentError.type == .card {

                    action.complete(
                        with: STPPaymentHandlerActionStatus.failed,
                        error: _error(
                            for: .paymentErrorCode,
                            apiErrorCode: lastPaymentError.code,
                            localizedDescription: lastPaymentError.message
                        )
                    )
                } else {
                    action.complete(
                        with: STPPaymentHandlerActionStatus.failed,
                        error: _error(for: .paymentErrorCode, apiErrorCode: lastPaymentError.code)
                    )
                }
            } else {
                action.complete(
                    with: STPPaymentHandlerActionStatus.failed,
                    error: _error(for: .paymentErrorCode)
                )
            }

        case .requiresConfirmation:
            action.complete(with: STPPaymentHandlerActionStatus.succeeded, error: nil)

        case .requiresAction:
            return true

        case .processing:
            if let type = paymentIntent.paymentMethod?.type,
                STPPaymentHandler._isProcessingIntentSuccess(for: type)
            {
                action.complete(with: STPPaymentHandlerActionStatus.succeeded, error: nil)
            } else {
                action.complete(
                    with: STPPaymentHandlerActionStatus.failed,
                    error: _error(for: .intentStatusErrorCode)
                )
            }

        case .succeeded:
            action.complete(with: STPPaymentHandlerActionStatus.succeeded, error: nil)

        case .requiresCapture:
            action.complete(with: STPPaymentHandlerActionStatus.succeeded, error: nil)

        case .canceled:
            action.complete(with: STPPaymentHandlerActionStatus.canceled, error: nil)
        }
        return false
    }

    func _handleAuthenticationForCurrentAction() {
        guard let currentAction else {
            stpAssertionFailure("Calling _handleAuthenticationForCurrentAction without a currentAction")
            let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: ["error_message": "Calling _handleAuthenticationForCurrentAction without a currentAction"])
            analyticsClient.log(analytic: errorAnalytic, apiClient: apiClient)
            return
        }
        guard let authenticationAction = currentAction.nextAction() else {
            stpAssertionFailure("Calling _handleAuthenticationForCurrentAction without a next action!")
            currentAction.complete(
                with: .failed,
                error: _error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "Calling _handleAuthenticationForCurrentAction without a next action!")
            )
            return
        }

        let failCurrentActionWithMissingNextActionDetails = {
            currentAction.complete(
                with: STPPaymentHandlerActionStatus.failed,
                error: self._error(
                    for: .unexpectedErrorCode,
                    loggingSafeErrorMessage: "Authentication action \(authenticationAction.type) is missing expected details."
                )
            )
        }

        switch authenticationAction.type {
        case .unknown:
            currentAction.complete(
                with: STPPaymentHandlerActionStatus.failed,
                error: self._error(
                    for: .unsupportedAuthenticationErrorCode,
                    loggingSafeErrorMessage: "Unknown authentication action type"
                )
            )
        case .redirectToURL:
            if let redirectToURL = authenticationAction.redirectToURL {
                let redirectURL = redirectToURL.followRedirects ?
                    // Pre-follow the redirect trampoline and pass the final URL to ASWebAuthenticationSession
                    // so that the consent dialog will show the correct domain (e.g. "klarna.com" instead of "stripe.com")
                    self.followRedirect(to: redirectToURL.url) :
                    redirectToURL.url
                _handleRedirect(to: redirectURL, withReturn: redirectToURL.returnURL, useWebAuthSession: redirectToURL.useWebAuthSession)
            } else {
                failCurrentActionWithMissingNextActionDetails()
            }

        case .alipayHandleRedirect:
            if let alipayHandleRedirect = authenticationAction.alipayHandleRedirect {
                _handleRedirect(
                    to: alipayHandleRedirect.nativeURL,
                    fallbackURL: alipayHandleRedirect.url,
                    return: alipayHandleRedirect.returnURL,
                    useWebAuthSession: false
                )
            } else {
                failCurrentActionWithMissingNextActionDetails()
            }

        case .weChatPayRedirectToApp:
            if let weChatPayRedirectToApp = authenticationAction.weChatPayRedirectToApp {
                _handleRedirect(
                    to: weChatPayRedirectToApp.nativeURL,
                    fallbackURL: nil,
                    return: nil,
                    useWebAuthSession: false
                )
            } else {
                failCurrentActionWithMissingNextActionDetails()
            }

        case .OXXODisplayDetails:
            if let hostedVoucherURL = authenticationAction.oxxoDisplayDetails?.hostedVoucherURL {
                self._handleRedirect(to: hostedVoucherURL, withReturn: nil, useWebAuthSession: false)
            } else {
                failCurrentActionWithMissingNextActionDetails()
            }

        case .boletoDisplayDetails:
            if let hostedVoucherURL = authenticationAction.boletoDisplayDetails?.hostedVoucherURL {
                self._handleRedirect(to: hostedVoucherURL, withReturn: nil, useWebAuthSession: false)
            } else {
                failCurrentActionWithMissingNextActionDetails()
            }

        case .multibancoDisplayDetails:
            if let hostedVoucherURL = authenticationAction.multibancoDisplayDetails?.hostedVoucherURL {
                self._handleRedirect(to: hostedVoucherURL, withReturn: nil, useWebAuthSession: false)
            } else {
                failCurrentActionWithMissingNextActionDetails()
            }

        case .useStripeSDK:
            if let useStripeSDK = authenticationAction.useStripeSDK {
                switch useStripeSDK.type {
                case .unknown:
                    currentAction.complete(with: STPPaymentHandlerActionStatus.failed, error: _error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "Unexpected useStripeSDK type"))

                case .threeDS2Fingerprint:
                    guard let threeDSService = currentAction.threeDS2Service else {
                        currentAction.complete(
                            with: STPPaymentHandlerActionStatus.failed,
                            error: _error(
                                for: .stripe3DS2ErrorCode,
                                loggingSafeErrorMessage: "Failed to initialize STDSThreeDS2Service."
                            )
                        )
                        return
                    }
                    var transaction: STDSTransaction?
                    var authRequestParams: STDSAuthenticationRequestParameters?

                    STDSSwiftTryCatch.try(
                        {
                            transaction = threeDSService.createTransaction(
                                forDirectoryServer: useStripeSDK.directoryServerID ?? "",
                                serverKeyID: useStripeSDK.directoryServerKeyID,
                                certificateString: useStripeSDK.directoryServerCertificate ?? "",
                                rootCertificateStrings: useStripeSDK.rootCertificateStrings ?? [],
                                withProtocolVersion: "2.2.0"
                            )
                            authRequestParams = transaction?.createAuthenticationRequestParameters()
                        },
                        catch: { exception in

                            self.analyticsClient
                                .log3DS2AuthenticationRequestParamsFailed(
                                    intentID: currentAction.intentStripeID,
                                    error: self._error(
                                        for: .stripe3DS2ErrorCode,
                                        loggingSafeErrorMessage: exception.description
                                    )
                                )

                            currentAction.complete(
                                with: STPPaymentHandlerActionStatus.failed,
                                error: self._error(
                                    for: .stripe3DS2ErrorCode,
                                    loggingSafeErrorMessage: exception.description
                                )
                            )
                        },
                        finallyBlock: {
                        }
                    )

                    analyticsClient.log3DS2AuthenticateAttempt(
                        intentID: currentAction.intentStripeID
                    )

                    guard let authParams = authRequestParams, let transaction else {
                        currentAction.complete(
                            with: STPPaymentHandlerActionStatus.failed,
                            error: self._error(
                                for: .stripe3DS2ErrorCode,
                                loggingSafeErrorMessage: transaction == nil ? "Missing transaction." : "Missing auth request params."
                            )
                        )
                        return
                    }
                    currentAction.threeDS2Transaction = transaction
                    currentAction.apiClient.authenticate3DS2(
                        authParams,
                        sourceIdentifier: useStripeSDK.threeDSSourceID ?? "",
                        returnURL: currentAction.returnURLString,
                        maxTimeout: currentAction.threeDSCustomizationSettings
                            .authenticationTimeout,
                        publishableKeyOverride: useStripeSDK.publishableKeyOverride
                    ) { (authenticateResponse, error) in
                        guard let authenticateResponse else {
                            let error = error ?? self._error(for: .stripe3DS2ErrorCode, loggingSafeErrorMessage: "Missing authenticate response")
                            currentAction.complete(with: .failed, error: error as NSError)
                            return
                        }
                        guard error == nil else {
                            currentAction.complete(with: .failed, error: error! as NSError)
                            return
                        }
                        if let aRes = authenticateResponse.authenticationResponse {

                            if aRes.isChallengeRequired {
                                let challengeParameters = STDSChallengeParameters(
                                    authenticationResponse: aRes
                                )

                                let doChallenge: STPVoidBlock = {
                                    var presentationError: NSError?

                                    guard self._canPresent(
                                        with: currentAction.authenticationContext,
                                        error: &presentationError
                                    ) else {
                                        currentAction.complete(
                                            with: .failed,
                                            error: presentationError
                                        )
                                        return
                                    }
                                    STDSSwiftTryCatch.try({
                                        let presentingViewController = currentAction.authenticationContext.authenticationPresentingViewController()
                                        let timeout = TimeInterval(currentAction.threeDSCustomizationSettings.authenticationTimeout * 60)
                                        if let paymentSheet = presentingViewController as? PaymentSheetAuthenticationContext {
                                            transaction.doChallenge(
                                                with: challengeParameters,
                                                challengeStatusReceiver: self,
                                                timeout: timeout
                                            ) { threeDSChallengeViewController, completion in
                                                paymentSheet.present(
                                                    threeDSChallengeViewController,
                                                    completion: completion
                                                )
                                            }
                                        } else {
                                            transaction.doChallenge(
                                                with: presentingViewController,
                                                challengeParameters: challengeParameters,
                                                challengeStatusReceiver: self,
                                                timeout: timeout
                                            )
                                        }
                                    }, catch: { exception in
                                        self.currentAction?.complete(
                                            with: .failed,
                                            error: self._error(
                                                for: .stripe3DS2ErrorCode,
                                                loggingSafeErrorMessage: exception.description
                                            )
                                        )
                                    }, finallyBlock: {}
                                    )
                                }

                                if currentAction.authenticationContext.responds(
                                    to: #selector(
                                        STPAuthenticationContext.prepare(forPresentation:))
                                ) {
                                    currentAction.authenticationContext.prepare?(
                                        forPresentation: doChallenge
                                    )
                                } else {
                                    doChallenge()
                                }

                            } else {
                                // Challenge not required, finish the flow.
                                transaction.close()
                                currentAction.threeDS2Transaction = nil
                                self.analyticsClient.log3DS2FrictionlessFlow(
                                    intentID: currentAction.intentStripeID
                                )

                                self._retrieveAndCheckIntentForCurrentAction()
                            }

                        } else if let fallbackURL = authenticateResponse.fallbackURL {
                            self._handleRedirect(
                                to: fallbackURL,
                                withReturn: URL(string: currentAction.returnURLString ?? ""), useWebAuthSession: false
                            )
                        } else {
                            currentAction.complete(
                                with: .failed,
                                error: self._error(
                                    for: .unexpectedErrorCode,
                                    loggingSafeErrorMessage: "3DS2 authenticate response missing both response and fallback URL."
                                )
                            )
                        }
                    }

                case .threeDS2Redirect:
                    guard let redirectURL = useStripeSDK.redirectURL else {
                        currentAction.complete(with: .failed, error: self._error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "Next action type is threeDS2Redirect but missing redirect URL."))
                        return
                    }
                    let returnURL: URL?
                    if let returnURLString = currentAction.returnURLString {
                        returnURL = URL(string: returnURLString)
                    } else {
                        returnURL = nil
                    }
                    _handleRedirect(to: redirectURL, withReturn: returnURL, useWebAuthSession: false)
                case .intentConfirmationChallenge:
                    _handleIntentConfirmationChallenge()
                }
            } else {
                failCurrentActionWithMissingNextActionDetails()
            }

        case .BLIKAuthorize:
            // The customer must authorize the transaction in their banking app within 1 minute
            if let presentingVC = currentAction.authenticationContext as? PaymentSheetAuthenticationContext {
                guard let currentAction = currentAction as? STPPaymentHandlerPaymentIntentActionParams else {
                    currentAction.complete(with: .failed, error: _error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "Handling BLIKAuthorize next action with SetupIntent is not supported"))
                    return
                }
                // If we are using PaymentSheet, PollingViewController will poll Stripe to determine success and complete the currentAction
                presentingVC.presentPollingVCForAction(action: currentAction, type: .blik, safariViewController: nil)
            } else {
                // The merchant integration should spin and poll their backend or Stripe to determine success
                currentAction.complete(with: .succeeded, error: nil)
            }
        case .verifyWithMicrodeposits:
            // The customer must authorize after the microdeposits appear in their bank account
            // which may take 1-2 business days
            currentAction.complete(with: .succeeded, error: nil)
        case .upiAwaitNotification:
            // The customer must authorize the transaction in their banking app within 5 minutes
            if let presentingVC = currentAction.authenticationContext as? PaymentSheetAuthenticationContext {
                guard let currentAction = currentAction as? STPPaymentHandlerPaymentIntentActionParams else {
                    currentAction.complete(with: .failed, error: _error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "Handling upiAwaitNotification next action with SetupIntent is not supported"))
                    return
                }
                // If we are using PaymentSheet, PollingViewController will poll Stripe to determine success and complete the currentAction
                presentingVC.presentPollingVCForAction(action: currentAction, type: .UPI, safariViewController: nil)
            } else {
                // The merchant integration should spin and poll their backend or Stripe to determine success
                currentAction.complete(with: .succeeded, error: nil)
            }
        case .cashAppRedirectToApp:
            guard let returnURL = URL(string: currentAction.returnURLString ?? "") else {
                assertionFailure(missingReturnURLErrorMessage)
                currentAction.complete(with: .failed, error: _error(for: .missingReturnURL))
                return
            }

            if let mobileAuthURL = authenticationAction.cashAppRedirectToApp?.mobileAuthURL {
                _handleRedirect(to: mobileAuthURL, fallbackURL: mobileAuthURL, return: returnURL, useWebAuthSession: false)
            } else {
                failCurrentActionWithMissingNextActionDetails()
            }
        case .payNowDisplayQrCode:
            guard let returnURL = URL(string: currentAction.returnURLString ?? "") else {
                assertionFailure(missingReturnURLErrorMessage)
                currentAction.complete(with: .failed, error: _error(for: .missingReturnURL))
                return
            }
            guard let hostedInstructionsURL = authenticationAction.payNowDisplayQrCode?.hostedInstructionsURL else {
                failCurrentActionWithMissingNextActionDetails()
                return
            }
            guard let presentingVC = currentAction.authenticationContext as? PaymentSheetAuthenticationContext else {
                assertionFailure("PayNow is not supported outside of PaymentSheet.")
                currentAction.complete(with: .failed, error: _error(for: .unsupportedAuthenticationErrorCode, loggingSafeErrorMessage: "PayNow is not supported outside of PaymentSheet."))
                return
            }
            guard let currentAction = currentAction as? STPPaymentHandlerPaymentIntentActionParams else {
                currentAction.complete(with: .failed, error: self._error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "Handling payNowDisplayQrCode next action with SetupIntent is not supported"))
                return
            }
            _handleRedirect(to: hostedInstructionsURL, fallbackURL: hostedInstructionsURL, return: returnURL, useWebAuthSession: false) { safariViewController in
                // Present the polling view controller behind the web view so we can start polling right away
                presentingVC.presentPollingVCForAction(action: currentAction, type: .paynow, safariViewController: safariViewController)
            }
        case .konbiniDisplayDetails:
            if let hostedVoucherURL = authenticationAction.konbiniDisplayDetails?.hostedVoucherURL {
                self._handleRedirect(to: hostedVoucherURL, withReturn: nil, useWebAuthSession: false)
            } else {
                failCurrentActionWithMissingNextActionDetails()
            }
        case .promptpayDisplayQrCode:
            guard let returnURL = URL(string: currentAction.returnURLString ?? "") else {
                assertionFailure(missingReturnURLErrorMessage)
                currentAction.complete(with: .failed, error: _error(for: .missingReturnURL))
                return
            }
            guard let hostedInstructionsURL = authenticationAction.promptPayDisplayQrCode?.hostedInstructionsURL else {
                failCurrentActionWithMissingNextActionDetails()
                return
            }
            guard let presentingVC = currentAction.authenticationContext as? PaymentSheetAuthenticationContext else {
                assertionFailure("PromptPay is not supported outside of PaymentSheet.")
                currentAction.complete(with: .failed, error: _error(for: .unsupportedAuthenticationErrorCode, loggingSafeErrorMessage: "PromptPay is not supported outside of PaymentSheet."))
                return
            }
            guard let currentAction = currentAction as? STPPaymentHandlerPaymentIntentActionParams else {
                currentAction.complete(with: .failed, error: self._error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "Handling promptpayDisplayQrCode next action with SetupIntent is not supported"))
                return
            }

            _handleRedirect(to: hostedInstructionsURL, fallbackURL: hostedInstructionsURL, return: returnURL, useWebAuthSession: false) { safariViewController in
                // Present the polling view controller behind the web view so we can start polling right away
                presentingVC.presentPollingVCForAction(action: currentAction, type: .promptPay, safariViewController: safariViewController)
            }
        case .swishHandleRedirect:
            guard let returnURL = URL(string: currentAction.returnURLString ?? "") else {
                assertionFailure(missingReturnURLErrorMessage)
                currentAction.complete(with: .failed, error: _error(for: .missingReturnURL))
                return
            }
            guard let mobileAuthURL = authenticationAction.swishHandleRedirect?.mobileAuthURL else {
                failCurrentActionWithMissingNextActionDetails()
                return
            }

            _handleRedirect(to: mobileAuthURL, withReturn: returnURL, useWebAuthSession: false)
        }
    }

    // A URLSessionTaskDelegate that can not be redirected by HTTP redirect codes. It is very focused on its task, you see.
    fileprivate class UnredirectableSessionDelegate: NSObject, URLSessionTaskDelegate {
        public func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
            // Don't get redirected, just call the completion handler
            completionHandler(nil)
        }
    }

    // Follow the first redirect for a url, but not any subsequent redirects
    @_spi(STP) public func followRedirect(to url: URL) -> URL {
        let urlSession = URLSession(configuration: StripeAPIConfiguration.sharedUrlSessionConfiguration, delegate: UnredirectableSessionDelegate(), delegateQueue: nil)
        let urlRequest = URLRequest(url: url)
        let blockingDataTaskSemaphore = DispatchSemaphore(value: 0)

        var resultingUrl = url
        let task = urlSession.dataTask(with: urlRequest) { _, response, error in
            defer {
                blockingDataTaskSemaphore.signal()
            }

            guard error == nil,
                let httpResponse = response as? HTTPURLResponse,
                (200...308).contains(httpResponse.statusCode),
                  let responseURLString = httpResponse.allHeaderFields["Location"] as? String,
                  let responseURL = URL(string: responseURLString)
            else {
                return
            }
            resultingUrl = responseURL
        }
        task.resume()
        blockingDataTaskSemaphore.wait()
        return resultingUrl
    }

    func _retryAfterDelay(retryCount: Int, delayTime: TimeInterval = 3, block: @escaping STPVoidBlock) {
        DispatchQueue.main.asyncAfter(deadline: .now() + delayTime) {
            block()
        }
    }

    /// Retrieves and checks the payment intent status for the current action.
    /// If pollingBudget is nil, this is the first attempt and a new budget is created.
    /// - Parameters:
    ///   - currentAction: Action parameters to process, defaults to self.currentAction
    ///   - pollingBudget: Existing polling budget, or nil for first attempt
    func _retrieveAndCheckIntentForCurrentAction(currentAction: STPPaymentHandlerActionParams? = nil, pollingBudget: PollingBudget? = nil) {
        // Alipay requires us to hit an endpoint before retrieving the PI, to ensure the status is up to date.
        let pingMarlinIfNecessary: ((STPPaymentHandlerPaymentIntentActionParams, @escaping STPVoidBlock) -> Void) = {
            currentAction,
            completionBlock in
            if let paymentMethod = currentAction.paymentIntent.paymentMethod,
                paymentMethod.type == .alipay,
                let alipayHandleRedirect = currentAction.nextAction()?.alipayHandleRedirect,
                let alipayReturnURL = alipayHandleRedirect.marlinReturnURL
            {

                // Make a request to the return URL
                let request: URLRequest = URLRequest(url: alipayReturnURL)
                let task: URLSessionDataTask = URLSession.shared.dataTask(
                    with: request,
                    completionHandler: { _, _, _ in
                        completionBlock()
                    }
                )
                task.resume()
            } else {
                completionBlock()
            }
        }
        guard let currentAction = currentAction ?? self.currentAction else {
            stpAssertionFailure("Calling _retrieveAndCheckIntentForCurrentAction without a currentAction")
            let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: ["error_message": "Calling _retrieveAndCheckIntentForCurrentAction without a currentAction"])
            analyticsClient.log(analytic: errorAnalytic, apiClient: apiClient)
            return
        }

        if let currentAction = currentAction as? STPPaymentHandlerPaymentIntentActionParams {
            pingMarlinIfNecessary(
                currentAction,
                {
                    let startDate = Date()
                    self.retrieveOrRefreshPaymentIntent(
                        currentAction: currentAction,
                        timeout: pollingBudget?.networkTimeout
                    ) { [self] paymentIntent, error in
                        guard let paymentIntent, error == nil else {
                            // Retry if polling budget allows. For the first call (no polling budget), create a minimal
                            // budget to allow one retry. This handles transient network errors.
                            let effectivePollingBudget = pollingBudget ?? PollingBudget(startDate: Date(), duration: 1)
                            if effectivePollingBudget.canPoll {
                                effectivePollingBudget.pollAfter {
                                    self._retrieveAndCheckIntentForCurrentAction(
                                        pollingBudget: pollingBudget
                                    )
                                }
                            } else {
                                let error = error ?? self._error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "Missing PaymentIntent.")
                                currentAction.complete(
                                    with: STPPaymentHandlerActionStatus.failed,
                                    error: error as NSError
                                )
                            }

                            return
                        }
                        currentAction.paymentIntent = paymentIntent
                        // If the transaction is still unexpectedly processing, refresh the PaymentIntent
                        // This could happen if, for example, a payment is approved in an SFSafariViewController, the user closes the sheet, and the approval races with this fetch.
                        if
                            let paymentMethod = paymentIntent.paymentMethod,
                            !STPPaymentHandler._isProcessingIntentSuccess(for: paymentMethod.type),
                            paymentIntent.status == .processing,
                            pollingBudget?.canPoll ?? true
                        {
                            let processingPollingBudget = pollingBudget ?? PollingBudget(startDate: startDate, duration: 30)
                            processingPollingBudget.pollAfter {
                                self._retrieveAndCheckIntentForCurrentAction(
                                    pollingBudget: processingPollingBudget
                                )
                            }
                        } else {
                            let requiresAction: Bool = self._handlePaymentIntentStatus(
                                forAction: currentAction
                            )
                            if requiresAction {
                                let paymentMethodType: STPPaymentMethodType? = {
                                    if let paymentMethod = paymentIntent.paymentMethod {
                                        return paymentMethod.type
                                    }
                                    if paymentIntent.isRedacted && paymentIntent.nextAction?.type == .useStripeSDK {
                                        // For now, we'll assume redacted PIs are card
                                        return .card
                                    }
                                    return nil
                                }()
                                guard let paymentMethodType else {
                                    currentAction.complete(
                                        with: STPPaymentHandlerActionStatus.failed,
                                        error: self._error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "PaymentIntent requires action but missing payment method type data.")
                                    )
                                    return
                                }
                                // If the status is still RequiresAction, the user exited from the redirect before the
                                // payment intent was updated. Consider it a cancel, unless it's a valid terminal next action
                                if self.isNextActionSuccessState(
                                    nextAction: paymentIntent.nextAction
                                ) {
                                    currentAction.complete(with: .succeeded, error: nil)
                                } else {
                                    // If this is a web-based 3DS2 transaction that is still in requires_action, we may just need to refresh the PI a few more times.
                                    // Also retry a few times for certain LPMs that experience latency updating the intent status after returning to the merchant's app
                                    let shouldRetryForCard = paymentMethodType == .card && paymentIntent.nextAction?.type == .useStripeSDK
                                    if paymentMethodType != .card || shouldRetryForCard, let pollingBudget = pollingBudget ?? .init(startDate: startDate, paymentMethodType: paymentMethodType), pollingBudget.canPoll {
                                        pollingBudget.pollAfter {
                                            self._retrieveAndCheckIntentForCurrentAction(
                                                pollingBudget: pollingBudget
                                            )
                                        }
                                    } else if paymentMethodType != .paynow && paymentMethodType != .promptPay {
                                        // For PayNow, we don't want to mark as canceled when the web view dismisses
                                        // Instead we rely on the presented PollingViewController to complete the currentAction
                                        self._markChallengeCanceled(currentAction: currentAction) { _, _ in
                                            // We don't forward cancelation errors
                                            currentAction.complete(with: .canceled, error: nil)
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            )
        } else if let currentAction = currentAction as? STPPaymentHandlerSetupIntentActionParams {
            let startDate = Date()
            retrieveOrRefreshSetupIntent(
                currentAction: currentAction,
                timeout: pollingBudget?.networkTimeout
            ) { setupIntent, error in
                guard let setupIntent, error == nil else {
                    // Retry if polling budget allows. For the first call (no polling budget), create a minimal
                    // budget to allow one retry. This handles transient network errors.
                    let effectivePollingBudget = pollingBudget ?? PollingBudget(startDate: Date(), duration: 1)
                    if effectivePollingBudget.canPoll {
                        effectivePollingBudget.pollAfter {
                            self._retrieveAndCheckIntentForCurrentAction(
                                pollingBudget: pollingBudget
                            )
                        }
                    } else {
                        let error = error ?? self._error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "Missing SetupIntent.")
                        currentAction.complete(
                            with: STPPaymentHandlerActionStatus.failed,
                            error: error as NSError
                        )
                    }
                    return
                }
                currentAction.setupIntent = setupIntent
                if let type = setupIntent.paymentMethod?.type,
                   !STPPaymentHandler._isProcessingIntentSuccess(for: type),
                   setupIntent.status == .processing,
                   pollingBudget?.canPoll ?? true
                {
                    let processingPollingBudget = pollingBudget ?? PollingBudget(startDate: startDate, duration: 30)
                    processingPollingBudget.pollAfter {
                        self._retrieveAndCheckIntentForCurrentAction(pollingBudget: processingPollingBudget)
                    }
                } else {
                    let requiresAction: Bool = self._handleSetupIntentStatus(
                        forAction: currentAction
                    )

                    if requiresAction {
                        guard let paymentMethod = setupIntent.paymentMethod else {
                            currentAction.complete(
                                with: STPPaymentHandlerActionStatus.failed,
                                error: self._error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "SetupIntent requires action but missing PaymentMethod.")
                            )
                            return
                        }
                        // If the status is still RequiresAction, the user exited from the redirect before the
                        // payment intent was updated. Consider it a cancel, unless it's a valid terminal next action
                        if self.isNextActionSuccessState(
                            nextAction: setupIntent.nextAction
                        ) {
                            currentAction.complete(with: .succeeded, error: nil)
                        } else {
                            // If this is a web-based 3DS2 transaction that is still in requires_action, we may just need to refresh the SI a few more times.
                            // Also retry a few times for certain LPMs that experience latency updating the intent status after returning to the merchant's app
                            let shouldRetryForCard = paymentMethod.type == .card && setupIntent.nextAction?.type == .useStripeSDK
                            if paymentMethod.type != .card || shouldRetryForCard, let pollingBudget = pollingBudget ?? .init(startDate: startDate, paymentMethodType: paymentMethod.type), pollingBudget.canPoll {
                                pollingBudget.pollAfter {
                                    self._retrieveAndCheckIntentForCurrentAction(
                                        pollingBudget: pollingBudget
                                    )
                                }
                            } else {
                                // If the status is still RequiresAction, the user exited from the redirect before the
                                // setup intent was updated. Consider it a cancel
                                self._markChallengeCanceled(currentAction: currentAction) { _, _ in
                                    // We don't forward cancelation errors
                                    currentAction.complete(with: .canceled, error: nil)
                                }
                            }
                        }
                    }
                }
            }
        } else {
            // TODO: Make currentAction an enum, stop optionally casting it
            stpAssert(false, "currentAction is an unknown type or nil intent.")
            currentAction.complete(
                with: .failed,
                error: _error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "currentAction is an unknown type or nil intent.")
            )
        }
    }

    @objc func _handleWillForegroundNotification() {
        NotificationCenter.default.removeObserver(
            self,
            name: UIApplication.willEnterForegroundNotification,
            object: nil
        )
        STPURLCallbackHandler.shared().unregisterListener(self)
        logURLRedirectNextActionFinished(returnType: .appForegrounded)
        _retrieveAndCheckIntentForCurrentAction()
    }

    @_spi(STP) public func _handleRedirect(to url: URL, withReturn returnURL: URL?, useWebAuthSession: Bool) {
        _handleRedirect(to: url, fallbackURL: url, return: returnURL, useWebAuthSession: useWebAuthSession)
    }

    /// Handles redirection to URLs using a native URL or a fallback URL and updates the current action.
    /// Redirects to an app if possible, if that fails opens the url in a web view
    /// - Parameters:
    ///     - nativeURL: A URL to be opened natively.
    ///     - fallbackURL: A secondary URL to be attempted if the native URL is not available.
    ///     - returnURL: The URL to be registered with the `STPURLCallbackHandler`.
    ///     - useWebAuthSession: Use ASWebAuthenticationSession instead of SFSafariViewController.
    ///     - completion: A completion block invoked after the URL redirection is handled. The SFSafariViewController used is provided as an argument, if it was used for the redirect.
    func _handleRedirect(to nativeURL: URL?, fallbackURL: URL?, return returnURL: URL?, useWebAuthSession: Bool, completion: ((SFSafariViewController?) -> Void)? = nil) {
        if let _redirectShim, let url = nativeURL ?? fallbackURL {
            _redirectShim(url, returnURL, true)
        }

        // During testing, the completion block is not called since the `UIApplication.open` completion block is never invoked.
        // As a workaround we invoke the completion in a defer block if the _redirectShim is not nil to simulate presenting a web view
        defer {
            if _redirectShim != nil {
                completion?(nil)
            }
        }

        var url = nativeURL
        guard let currentAction else {
            stpAssertionFailure("Calling _handleRedirect without a currentAction")
            let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: ["error_message": "Calling _handleRedirect without a currentAction"])
            analyticsClient.log(analytic: errorAnalytic, apiClient: apiClient)
            return
        }
        if let returnURL {
            STPURLCallbackHandler.shared().register(self, for: returnURL)
        }

        // Open the link in SafariVC
        let presentSFViewControllerBlock: (() -> Void) = {
            let context = currentAction.authenticationContext

            let presentingViewController = context.authenticationPresentingViewController()

            let doChallenge: STPVoidBlock = {
                var presentationError: NSError?
                guard self._canPresent(with: context, error: &presentationError) else {
                    currentAction.complete(
                        with: STPPaymentHandlerActionStatus.failed,
                        error: presentationError
                    )
                    return
                }

                if let fallbackURL,
                    ["http", "https"].contains(fallbackURL.scheme)
                {
                    if useWebAuthSession {
                        if self._redirectShim != nil {
                            // No-op if the redirect shim is active, as we don't want to open the consent dialog. We'll call the completion block automatically.
                            return
                        }
                        self.logURLRedirectNextActionStarted(redirectType: .ASWebAuthenticationSession)
                        // Note that ASWebAuthenticationSession will also close based on the `redirectURL` defined in the app's Info.plist if called within the ASWAS,
                        // not only via this callbackURLScheme.
                        let asWebAuthenticationSession = ASWebAuthenticationSession(url: fallbackURL, callbackURLScheme: "stripesdk", completionHandler: { _, _ in
                            if context.responds(
                                to: #selector(STPAuthenticationContext.authenticationContextWillDismiss(_:))
                            ) {
                                // This isn't great, but UIViewController is non-nil in the protocol. Maybe it's better to still call it, even if the VC isn't useful?
                                context.authenticationContextWillDismiss?(UIViewController())
                            }
                            // This isn't great, but UIViewController is non-nil in the protocol. Maybe it's better to still call it, even if the VC isn't useful?
                            self.callContextDidDismissIfNeeded(context, UIViewController())
                            STPURLCallbackHandler.shared().unregisterListener(self)
                            self.logURLRedirectNextActionFinished(returnType: .ASWebAuthenticationSession)
                            self._retrieveAndCheckIntentForCurrentAction()
                            self.asWebAuthenticationSession = nil
                        })
                        asWebAuthenticationSession.prefersEphemeralWebBrowserSession = false
                        asWebAuthenticationSession.presentationContextProvider = currentAction
                        self.asWebAuthenticationSession = asWebAuthenticationSession
                        if context.responds(to: #selector(STPAuthenticationContext.prepare(forPresentation:))) {
                            context.prepare?(forPresentation: {
                                asWebAuthenticationSession.start()
                            })
                        } else {
                            asWebAuthenticationSession.start()
                        }
                    } else {
                        self.logURLRedirectNextActionStarted(redirectType: .SFSafariViewController)
                        let safariViewController = SFSafariViewController(url: fallbackURL)
                        safariViewController.modalPresentationStyle = .overFullScreen
#if !os(visionOS)
                        safariViewController.dismissButtonStyle = .close
                        safariViewController.delegate = self
#endif
                        if context.responds(
                            to: #selector(STPAuthenticationContext.configureSafariViewController(_:))
                        ) {
                            context.configureSafariViewController?(safariViewController)
                        }
                        self.safariViewController = safariViewController
                        presentingViewController.present(safariViewController, animated: true, completion: {
                            completion?(safariViewController)
                        })
                    }
                } else {
                    currentAction.complete(
                        with: STPPaymentHandlerActionStatus.failed,
                        error: self._error(for: .requiredAppNotAvailable)
                    )
                }
            }
            if context.responds(to: #selector(STPAuthenticationContext.prepare(forPresentation:))) {
                context.prepare?(forPresentation: doChallenge)
            } else {
                doChallenge()
            }
        }

        // Redirect to an app
        // We don't want universal links to open up Safari, but we do want to allow custom URL schemes
        var options: [UIApplication.OpenExternalURLOptionsKey: Any] = [:]
        #if !targetEnvironment(macCatalyst)
        if let scheme = url?.scheme, scheme == "http" || scheme == "https" {
            options[UIApplication.OpenExternalURLOptionsKey.universalLinksOnly] = true
        }
        #endif

        // If we're simulating app-to-app redirects, we always want to open the URL in Safari instead of an in-app web view.
        // We'll tell Safari to open all URLs, not just universal links.
        // If we don't have a nativeURL, we should open the fallbackURL in Safari instead.
        if simulateAppToAppRedirect {
            options[UIApplication.OpenExternalURLOptionsKey.universalLinksOnly] = false
            url = nativeURL ?? fallbackURL
        }

        // We don't check canOpenURL before opening the URL because that requires users to pre-register the custom URL schemes
        if let url = url {
            UIApplication.shared.open(
                url,
                options: options,
                completionHandler: { success in
                    if !success {
                        // no app installed, launch safari view controller
                        presentSFViewControllerBlock()
                    } else {
                        self.logURLRedirectNextActionStarted(redirectType: .nativeApp)
                        completion?(nil)
                        NotificationCenter.default.addObserver(
                            self,
                            selector: #selector(self._handleWillForegroundNotification),
                            name: UIApplication.willEnterForegroundNotification,
                            object: nil
                        )
                    }
                }
            )
        } else {
            presentSFViewControllerBlock()
        }
    }

    /// Handles intent confirmation challenge by presenting a WebView with the Stripe-hosted challenge page
    func _handleIntentConfirmationChallenge() {
        guard let currentAction else {
            stpAssertionFailure("Calling _handleIntentConfirmationChallenge without a currentAction")
            let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: ["error_message": "Calling _handleIntentConfirmationChallenge without a currentAction"])
            analyticsClient.log(analytic: errorAnalytic, apiClient: apiClient)
            return
        }

        if #available(iOS 14.0, *) {
            // Extract client secret
            let clientSecret: String
            if let piAction = currentAction as? STPPaymentHandlerPaymentIntentActionParams {
                clientSecret = piAction.paymentIntent.clientSecret
            } else if let siAction = currentAction as? STPPaymentHandlerSetupIntentActionParams {
                clientSecret = siAction.setupIntent.clientSecret
            } else {
                currentAction.complete(
                    with: .failed,
                    error: _error(
                        for: .unexpectedErrorCode,
                        loggingSafeErrorMessage: "Unable to extract client secret for intent confirmation challenge"
                    )
                )
                return
            }

            // Extract publishable key
            guard let publishableKey = apiClient.publishableKey else {
                currentAction.complete(
                    with: .failed,
                    error: _error(
                        for: .unexpectedErrorCode,
                        loggingSafeErrorMessage: "Unable to extract publishable key for intent confirmation challenge"
                    )
                )
                return
            }

            let context = currentAction.authenticationContext
            var presentationError: NSError?
            guard _canPresent(with: context, error: &presentationError) else {
                currentAction.complete(with: .failed, error: presentationError)
                return
            }

            let presentingVC = context.authenticationPresentingViewController()

                let challengeVC = IntentConfirmationChallengeViewController(
                    publishableKey: publishableKey,
                    clientSecret: clientSecret
                ) { [weak self] result in
                    guard let self = self else { return }

                    // Dismiss the challenge view
                    presentingVC.dismiss(animated: true) {
                        switch result {
                        case .success:
                            // The web page handled the next action via Stripe.js
                            // Now retrieve the updated intent to check its status
                            self._retrieveAndCheckIntentForCurrentAction()

                        case .failure(let error):
                            currentAction.complete(with: .failed, error: error as NSError)
                        }
                    }
                }

            let doChallenge: STPVoidBlock = {
                challengeVC.modalPresentationStyle = .overFullScreen
                challengeVC.modalTransitionStyle = .crossDissolve
                presentingVC.present(challengeVC, animated: true, completion: nil)
            }

            if context.responds(to: #selector(STPAuthenticationContext.prepare(forPresentation:))) {
                context.prepare?(forPresentation: doChallenge)
            } else {
                doChallenge()
            }
        } else { // Intent confirmation challenge should be gated to iOS versions 14.0+
            let unsupportedVersionErrorMessage = "Unable to perform intent confirmation challenge. Requires iOS version 14.0 or later."
            stpAssertionFailure(unsupportedVersionErrorMessage)
            currentAction.complete(
                with: .failed,
                error: _error(
                    for: .unexpectedErrorCode,
                    loggingSafeErrorMessage: unsupportedVersionErrorMessage
                )
            )
        }
    }

    /// Checks if authenticationContext.authenticationPresentingViewController can be presented on.
    /// @note Call this method after `prepareAuthenticationContextForPresentation:`
    func _canPresent(
        with authenticationContext: STPAuthenticationContext,
        error: inout NSError?
    )
        -> Bool
    {
        // Always allow in tests:
        if NSClassFromString("XCTest") != nil {
            return true
        }
        let presentingViewController =
            authenticationContext.authenticationPresentingViewController()
        var canPresent = true
        var loggingSafeErrorMessage: String?

        // Is it in the window hierarchy?
        if presentingViewController.viewIfLoaded?.window == nil {
            canPresent = false
            loggingSafeErrorMessage =
                "authenticationPresentingViewController is not in the window hierarchy. You should probably return the top-most view controller instead."
        }

        // Is it already presenting something?
        if presentingViewController.presentedViewController != nil {
            canPresent = false
            loggingSafeErrorMessage =
                "authenticationPresentingViewController is already presenting. You should probably dismiss the presented view controller in `prepareAuthenticationContextForPresentation`."
        }

        if !canPresent {
            error = _error(
                for: .requiresAuthenticationContextErrorCode,
                loggingSafeErrorMessage: loggingSafeErrorMessage
            )
        }
        return canPresent
    }

    /// Check if the intent.nextAction is expected state after a successful on-session transaction
    /// e.g. for voucher-based payment methods like OXXO that require out-of-band payment
    func isNextActionSuccessState(nextAction: STPIntentAction?) -> Bool {
        if let nextAction = nextAction {
            switch nextAction.type {
            case .unknown,
                .redirectToURL,
                .useStripeSDK,
                .alipayHandleRedirect,
                .weChatPayRedirectToApp,
                .cashAppRedirectToApp,
                .payNowDisplayQrCode,
                .promptpayDisplayQrCode,
                .swishHandleRedirect:
                return false
            case .OXXODisplayDetails,
                .boletoDisplayDetails,
                .konbiniDisplayDetails,
                .verifyWithMicrodeposits,
                .BLIKAuthorize,
                .upiAwaitNotification,
                .multibancoDisplayDetails:
                return true
            }
        }
        return false
    }

    // This is only called after web-redirects because native 3DS2 cancels go directly
    // to the ACS
    func _markChallengeCanceled(currentAction: STPPaymentHandlerActionParams, completion: @escaping STPBooleanSuccessBlock) {
        guard let nextAction = currentAction.nextAction() else {
            stpAssert(false, "Calling _markChallengeCanceled without nextAction.")
            let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: ["error_message": "Calling _markChallengeCanceled without nextAction."])
            analyticsClient.log(analytic: errorAnalytic, apiClient: apiClient)
            return
        }

        var threeDSSourceID: String?
        switch nextAction.type {
        case .redirectToURL:
            threeDSSourceID = nextAction.redirectToURL?.threeDSSourceID
        case .useStripeSDK:
            threeDSSourceID = nextAction.useStripeSDK?.threeDSSourceID
        case .OXXODisplayDetails, .alipayHandleRedirect, .unknown, .BLIKAuthorize,
            .weChatPayRedirectToApp, .boletoDisplayDetails, .verifyWithMicrodeposits,
            .upiAwaitNotification, .cashAppRedirectToApp, .konbiniDisplayDetails, .payNowDisplayQrCode,
            .promptpayDisplayQrCode, .swishHandleRedirect, .multibancoDisplayDetails:
            break
        }

        guard let cancelSourceID = threeDSSourceID else {
            // If there's no threeDSSourceID, there's nothing for us to cancel
            completion(true, nil)
            return
        }

        if let currentAction = currentAction as? STPPaymentHandlerPaymentIntentActionParams {
            guard
                currentAction.paymentIntent.paymentMethod?.card != nil || currentAction.paymentIntent.paymentMethod?.link != nil
            else {
                // Only cancel 3DS auth on payment method types that support 3DS.
                completion(true, nil)
                return
            }

            analyticsClient.log3DS2RedirectUserCanceled(
                intentID: currentAction.intentStripeID
            )

            let intentID = nextAction.useStripeSDK?.threeDS2IntentOverride ?? currentAction.paymentIntent.stripeId

            currentAction.apiClient.cancel3DSAuthentication(
                forPaymentIntent: intentID,
                withSource: cancelSourceID,
                publishableKeyOverride: nextAction.useStripeSDK?.publishableKeyOverride
            ) { paymentIntent, error in
                if let paymentIntent {
                    currentAction.paymentIntent = paymentIntent
                }
                completion(paymentIntent != nil, error)
            }
        } else if let currentAction = currentAction as? STPPaymentHandlerSetupIntentActionParams {
            let setupIntent = currentAction.setupIntent
            guard setupIntent.paymentMethod?.card != nil || setupIntent.paymentMethod?.link != nil
            else {
                // Only cancel 3DS auth on payment method types that support 3DS.
                completion(true, nil)
                return
            }

            analyticsClient.log3DS2RedirectUserCanceled(
                intentID: currentAction.intentStripeID
            )

            let intentID = nextAction.useStripeSDK?.threeDS2IntentOverride ?? setupIntent.stripeID

            currentAction.apiClient.cancel3DSAuthentication(
                forSetupIntent: intentID,
                withSource: cancelSourceID,
                publishableKeyOverride: nextAction.useStripeSDK?.publishableKeyOverride
            ) { retrievedSetupIntent, error in
                if let retrievedSetupIntent {
                    currentAction.setupIntent = retrievedSetupIntent
                }
                completion(retrievedSetupIntent != nil, error)
            }
        } else {
            // TODO: Make currentAction an enum, stop optionally casting it
            stpAssert(false, "currentAction is an unknown type or nil intent.")
            currentAction.complete(
                with: .failed,
                error: _error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "currentAction is an unknown type or nil intent.")
            )
        }
    }

    static let maxChallengeRetries = 5
    func _markChallengeCompleted(
        withCompletion completion: @escaping STPBooleanSuccessBlock,
        retryCount: Int = maxChallengeRetries
    ) {
        guard let currentAction,
              let useStripeSDK = currentAction.nextAction()?.useStripeSDK,
              let threeDSSourceID = useStripeSDK.threeDSSourceID
        else {
            let errorMessage: String = {
                if currentAction == nil {
                    return "Attempted to mark challenge completed, but currentAction is nil"
                } else if currentAction?.nextAction()?.useStripeSDK == nil {
                    return "Attempted to mark challenge completed, but useStripeSDK is nil"
                } else {
                    return "Attempted to mark challenge completed, but threeDSSourceID is nil"
                }
            }()
            stpAssertionFailure(errorMessage)
            completion(false, self._error(for: .unexpectedErrorCode, loggingSafeErrorMessage: errorMessage))
            return
        }

        func retrieveIntent(action: STPPaymentHandlerActionParams, completion: @escaping STPBooleanSuccessBlock) {
            if let paymentIntentAction = action as? STPPaymentHandlerPaymentIntentActionParams {
                currentAction.apiClient.retrievePaymentIntent(
                    withClientSecret: paymentIntentAction.paymentIntent.clientSecret,
                    expand: ["payment_method"]
                ) { paymentIntent, retrieveError in
                    if let paymentIntent {
                        paymentIntentAction.paymentIntent = paymentIntent
                    }
                    completion(paymentIntent != nil, retrieveError)
                }
            } else if let setupIntentAction = action as? STPPaymentHandlerSetupIntentActionParams {
                currentAction.apiClient.retrieveSetupIntent(
                    withClientSecret: setupIntentAction.setupIntent.clientSecret,
                    expand: ["payment_method"]
                ) { retrievedSetupIntent, retrieveError in
                    if let retrievedSetupIntent {
                        setupIntentAction.setupIntent = retrievedSetupIntent
                    }
                    completion(retrievedSetupIntent != nil, retrieveError)
                }
            } else {
                // TODO: Make currentAction an enum, stop optionally casting it
                stpAssert(false, "currentAction is an unknown type or nil intent.")
                currentAction.complete(
                    with: .failed,
                    error: self._error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "currentAction is an unknown type or nil intent.")
                )
            }
        }

        currentAction.apiClient.complete3DS2Authentication(
            forSource: threeDSSourceID,
            publishableKeyOverride: useStripeSDK.publishableKeyOverride
        ) { success, error in
            if success {
               retrieveIntent(action: currentAction, completion: completion)
            } else {
                // This isn't guaranteed to succeed if the ACS isn't ready yet.
                // Try it a few more times if it fails with a 400. (RUN_MOBILESDK-126)
                if retryCount > 0
                    && (error as NSError?)?.code == STPErrorCode.invalidRequestError.rawValue
                {
                    self._retryAfterDelay(
                        retryCount: retryCount,
                        block: {
                            self._markChallengeCompleted(
                                withCompletion: completion,
                                retryCount: retryCount - 1
                            )
                        }
                    )
                } else {
                    // Completing the 3DS2 action failed, try to retrieve the intent anyways:
                    retrieveIntent(action: currentAction, completion: completion)
                }
            }
        }
    }

    func retrieveOrRefreshPaymentIntent(currentAction: STPPaymentHandlerPaymentIntentActionParams,
                                        timeout: NSNumber?,
                                        completion: @escaping STPPaymentIntentCompletionBlock) {
        let paymentMethodType = currentAction.paymentIntent.paymentMethod?.type ?? .unknown

        if paymentMethodType.supportsRefreshing {
            currentAction.apiClient.refreshPaymentIntent(withClientSecret: currentAction.paymentIntent.clientSecret,
                                                         completion: completion)
        } else {
            currentAction.apiClient.retrievePaymentIntent(withClientSecret: currentAction.paymentIntent.clientSecret,
                                                          expand: ["payment_method"],
                                                          timeout: timeout,
                                                          completion: completion)
        }
    }

    func retrieveOrRefreshSetupIntent(currentAction: STPPaymentHandlerSetupIntentActionParams,
                                      timeout: NSNumber?,
                                      completion: @escaping STPSetupIntentCompletionBlock) {
        let paymentMethodType = currentAction.setupIntent.paymentMethod?.type ?? .unknown

        if paymentMethodType.supportsRefreshing {
            currentAction.apiClient.refreshSetupIntent(withClientSecret: currentAction.setupIntent.clientSecret,
                                                       completion: completion)
        } else {
            currentAction.apiClient.retrieveSetupIntent(withClientSecret: currentAction.setupIntent.clientSecret,
                                                        expand: ["payment_method"],
                                                        timeout: timeout,
                                                        completion: completion)
        }
    }

    // MARK: - Errors
    /// - Parameter loggingSafeErrorMessage: Error details that are safe to log i.e. don't contain PII/PDE or secrets.
    @_spi(STP) public func _error(
        for errorCode: STPPaymentHandlerErrorCode,
        apiErrorCode: String? = nil,
        loggingSafeErrorMessage: String? = nil,
        localizedDescription: String? = nil
    ) -> NSError {
        var userInfo = [String: String]()
        userInfo[STPError.errorMessageKey] = loggingSafeErrorMessage
        userInfo[NSLocalizedDescriptionKey] = localizedDescription
        switch errorCode {
        // 3DS(2) flow expected user errors
        case .notAuthenticatedErrorCode:
            userInfo[NSLocalizedDescriptionKey] = STPLocalizedString(
                "We are unable to authenticate your payment method. Please choose a different payment method and try again.",
                "Error when 3DS2 authentication failed (e.g. customer entered the wrong code)"
            )

        case .timedOutErrorCode:
            userInfo[NSLocalizedDescriptionKey] = STPLocalizedString(
                "Timed out authenticating your payment method -- try again",
                "Error when 3DS2 authentication timed out."
            )

        // PaymentIntent has an unexpected status
        case .intentStatusErrorCode:
            // The PI's status is processing or unknown
            userInfo[STPError.errorMessageKey] =
                userInfo[STPError.errorMessageKey] ?? "The PaymentIntent status cannot be handled."
            userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

        case .unsupportedAuthenticationErrorCode:
            userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

        case .requiredAppNotAvailable:
            userInfo[STPError.errorMessageKey] =
                userInfo[STPError.errorMessageKey]
                ?? "This PaymentIntent action requires an app, but the app is not installed or the request to open the app was denied."
            userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

        // Programming errors
        case .requiresPaymentMethodErrorCode:
            userInfo[STPError.errorMessageKey] =
                userInfo[STPError.errorMessageKey]
                ?? "The PaymentIntent requires a PaymentMethod or Source to be attached before using STPPaymentHandler."
            userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

        case .noConcurrentActionsErrorCode:
            userInfo[STPError.errorMessageKey] =
                userInfo[STPError.errorMessageKey]
                ?? "The current action is not yet completed. STPPaymentHandler does not support concurrent calls to its API."
            userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

        case .requiresAuthenticationContextErrorCode:
            userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

        case .missingReturnURL:
            userInfo[STPError.errorMessageKey] = missingReturnURLErrorMessage
            userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

        // Exceptions thrown from the Stripe3DS2 SDK. Other errors are reported via STPChallengeStatusReceiver.
        case .stripe3DS2ErrorCode:
            userInfo[STPError.errorMessageKey] =
                userInfo[STPError.errorMessageKey] ?? "There was an error in the Stripe3DS2 SDK."
            userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

        // Confirmation errors (eg card was declined)
        case .paymentErrorCode:
            userInfo[STPError.errorMessageKey] =
                userInfo[STPError.errorMessageKey]
                ?? "There was an error confirming the Intent. Inspect the `paymentIntent.lastPaymentError` or `setupIntent.lastSetupError` property."

            userInfo[NSLocalizedDescriptionKey] =
                apiErrorCode.flatMap({ NSError.Utils.localizedMessage(fromAPIErrorCode: $0) })
                ?? userInfo[NSLocalizedDescriptionKey]
                ?? NSError.stp_unexpectedErrorMessage()

        // Client secret format error
        case .invalidClientSecret:
            userInfo[STPError.errorMessageKey] =
                userInfo[STPError.errorMessageKey]
                ?? "The provided Intent client secret does not match the expected client secret format. Make sure your server is returning the correct value and that is passed to `STPPaymentHandler`."
            userInfo[NSLocalizedDescriptionKey] =
                userInfo[NSLocalizedDescriptionKey] ?? NSError.stp_unexpectedErrorMessage()

        case .unexpectedErrorCode:
            break
        }
        return STPPaymentHandlerError(code: errorCode, loggingSafeUserInfo: userInfo) as NSError
    }

    func callContextDidDismissIfNeeded(_ context: (any STPAuthenticationContext)?, _ viewController: UIViewController?) {
        guard let context, let viewController else { return }

        if context.responds(
            to: #selector(STPAuthenticationContext.authenticationContextDidDismiss(_:))
        )
        {
            context.authenticationContextDidDismiss?(viewController)
        }
    }
}

/// STPPaymentHandler errors (i.e. errors that are created by the STPPaymentHandler class and have a corresponding STPPaymentHandlerErrorCode) used to be NSErrors.
/// This struct exists so that these errors can be Swift errors to conform to AnalyticLoggableError, while still looking like the old NSErrors to users (i.e. same domain and code).
struct STPPaymentHandlerError: Error, CustomNSError, AnalyticLoggableError {
    // AnalyticLoggableError properties
    let analyticsErrorType: String = errorDomain
    let analyticsErrorCode: String
    let additionalNonPIIErrorDetails: [String: Any]

    // CustomNSError properties, to not break old behavior when this was an NSError
    static let errorDomain: String = STPPaymentHandler.errorDomain
    let errorUserInfo: [String: Any]
    let errorCode: Int

    init(code: STPPaymentHandlerErrorCode, loggingSafeUserInfo: [String: String]) {
        errorCode = code.rawValue
        // Set analytics error code to the description (e.g. "invalidClientSecret")
        analyticsErrorCode = code.description
        errorUserInfo = loggingSafeUserInfo
        additionalNonPIIErrorDetails = loggingSafeUserInfo
    }
}

#if !os(visionOS)
extension STPPaymentHandler: SFSafariViewControllerDelegate {
    // MARK: - SFSafariViewControllerDelegate
    /// :nodoc:
    @objc
    public func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
        let context = currentAction?.authenticationContext
        if context?.responds(
            to: #selector(STPAuthenticationContext.authenticationContextWillDismiss(_:))
        ) ?? false {
            context?.authenticationContextWillDismiss?(controller)
        }

        callContextDidDismissIfNeeded(context, controller)

        safariViewController = nil
        STPURLCallbackHandler.shared().unregisterListener(self)
        logURLRedirectNextActionFinished(returnType: .SFSafariViewController)
        _retrieveAndCheckIntentForCurrentAction()
    }
}
#endif

/// :nodoc:
@_spi(STP) extension STPPaymentHandler: STPURLCallbackListener {
    /// :nodoc:
    @_spi(STP) public func handleURLCallback(_ url: URL) -> Bool {
        if currentAction?.nextAction()?.redirectToURL?.useWebAuthSession ?? false {
            // Don't handle the URL — If a user clicks the URL in ASWebAuthenticationSession, ASWebAuthenticationSession will handle it internally.
            // If we're returning from another app via a URL while ASWebAuthenticationSession is open, it's likely that the PM initiated a redirect to another app
            // (such as a banking app) and is waiting for a response from that app.
            return false
        }
        logURLRedirectNextActionFinished(returnType: .returnURLCallback)
        // Note: At least my iOS 15 device, willEnterForegroundNotification is triggered before this method when returning from another app, which means this method isn't called because it unregisters from STPURLCallbackHandler.
        let context = currentAction?.authenticationContext
        if context?.responds(
            to: #selector(STPAuthenticationContext.authenticationContextWillDismiss(_:))
        ) ?? false,
            let safariViewController = safariViewController
        {
            context?.authenticationContextWillDismiss?(safariViewController)
        }

        NotificationCenter.default.removeObserver(
            self,
            name: UIApplication.willEnterForegroundNotification,
            object: nil
        )
        STPURLCallbackHandler.shared().unregisterListener(self)
        safariViewController?.dismiss(animated: true) {
            self.callContextDidDismissIfNeeded(context, self.safariViewController)
            self.safariViewController = nil
        }
        _retrieveAndCheckIntentForCurrentAction()
        return true
    }
}

extension STPPaymentHandler {
    // MARK: - STPChallengeStatusReceiver
    /// :nodoc:
    @objc(transaction:didCompleteChallengeWithCompletionEvent:)
    dynamic func transaction(
        _ transaction: STDSTransaction,
        didCompleteChallengeWith completionEvent: STDSCompletionEvent
    ) {
        guard let currentAction else {
            stpAssertionFailure("Calling didCompleteChallengeWith without currentAction.")
            let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: ["error_message": "Calling didCompleteChallengeWith without currentAction."])
            analyticsClient.log(analytic: errorAnalytic, apiClient: apiClient)
            return
        }
        let transactionStatus = completionEvent.transactionStatus
        analyticsClient.log3DS2ChallengeFlowCompleted(
                        intentID: currentAction.intentStripeID,
            uiType: transaction.presentedChallengeUIType
        )
        if transactionStatus == "Y" {
            _markChallengeCompleted(withCompletion: { _, _ in
                if let currentAction = self.currentAction
                    as? STPPaymentHandlerPaymentIntentActionParams
                {
                    let requiresAction = self._handlePaymentIntentStatus(forAction: currentAction)
                    if requiresAction {
                        stpAssertionFailure("3DS2 challenge completed, but the PaymentIntent is still requiresAction")
                        currentAction.complete(
                            with: .failed,
                            error: self._error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "3DS2 challenge completed, but the PaymentIntent is still requiresAction")
                        )
                    }
                } else if let currentAction = self.currentAction
                    as? STPPaymentHandlerSetupIntentActionParams
                {
                    let requiresAction = self._handleSetupIntentStatus(forAction: currentAction)
                    if requiresAction {
                        stpAssertionFailure("3DS2 challenge completed, but the SetupIntent is still requiresAction")
                        currentAction.complete(
                            with: STPPaymentHandlerActionStatus.failed,
                            error: self._error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "3DS2 challenge completed, but the SetupIntent is still requiresAction")
                        )
                    }
                }
            })
        } else {
            // going to ignore the rest of the status types because they provide more detail than we require
            _markChallengeCompleted(withCompletion: { _, _ in
                currentAction.complete(
                    with: STPPaymentHandlerActionStatus.failed,
                    error: self._error(
                        for: .notAuthenticatedErrorCode,
                        loggingSafeErrorMessage: "Failed with transaction_status: \(transactionStatus)"
                    )
                )
            })
        }
    }

    /// :nodoc:
    @objc(transactionDidCancel:)
    dynamic func transactionDidCancel(_ transaction: STDSTransaction) {
        guard let currentAction else {
            stpAssertionFailure("Calling transactionDidCancel without currentAction.")
            let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: ["error_message": "Calling transactionDidCancel without currentAction."])
            analyticsClient.log(analytic: errorAnalytic, apiClient: apiClient)
            return
        }

        analyticsClient.log3DS2ChallengeFlowUserCanceled(
            intentID: currentAction.intentStripeID,
            uiType: transaction.presentedChallengeUIType
        )
        _markChallengeCompleted(withCompletion: { _, _ in
            // we don't forward cancelation errors
            currentAction.complete(with: STPPaymentHandlerActionStatus.canceled, error: nil)
        })
    }

    /// :nodoc:
    @objc(transactionDidTimeOut:)
    dynamic func transactionDidTimeOut(_ transaction: STDSTransaction) {
        guard let currentAction else {
            stpAssertionFailure("Calling transactionDidTimeOut without currentAction.")
            let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: ["error_message": "Calling transactionDidTimeOut without currentAction."])
            analyticsClient.log(analytic: errorAnalytic, apiClient: apiClient)
            return
        }

        analyticsClient.log3DS2ChallengeFlowTimedOut(
            intentID: currentAction.intentStripeID,
            uiType: transaction.presentedChallengeUIType
        )
        _markChallengeCompleted(withCompletion: { _, _ in
            currentAction.complete(
                with: STPPaymentHandlerActionStatus.failed,
                error: self._error(for: .timedOutErrorCode)
            )
        })

    }

    /// :nodoc:
    @objc(transaction:didErrorWithProtocolErrorEvent:)
    dynamic func transaction(
        _ transaction: STDSTransaction,
        didErrorWith protocolErrorEvent: STDSProtocolErrorEvent
    ) {
        guard let currentAction else {
            stpAssertionFailure("Calling didErrorWithProtocolErrorEvent without currentAction.")
            let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: ["error_message": "Calling didErrorWithProtocolErrorEvent without currentAction."])
            analyticsClient.log(analytic: errorAnalytic, apiClient: apiClient)
            return
        }

        _markChallengeCompleted(withCompletion: { [weak self] _, _ in
            // Add localizedError to the 3DS2 SDK error
            let threeDSError = protocolErrorEvent.errorMessage.nsErrorValue() as NSError
            var userInfo = threeDSError.userInfo
            userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

            let localizedError = NSError(
                domain: threeDSError.domain,
                code: threeDSError.code,
                userInfo: userInfo
            )
            self?.analyticsClient.log3DS2ChallengeFlowErrored(
                intentID: currentAction.intentStripeID,
                error: localizedError
            )
            currentAction.complete(
                with: .failed,
                error: localizedError
            )
        })
    }

    /// :nodoc:
    @objc(transaction:didErrorWithRuntimeErrorEvent:)
    dynamic func transaction(
        _ transaction: STDSTransaction,
        didErrorWith runtimeErrorEvent: STDSRuntimeErrorEvent
    ) {
        guard let currentAction else {
            stpAssertionFailure("Calling didErrorWithRuntimeErrorEvent without currentAction.")
            let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: ["error_message": "Calling didErrorWithRuntimeErrorEvent without currentAction."])
            analyticsClient.log(analytic: errorAnalytic, apiClient: apiClient)
            return
        }

        _markChallengeCompleted(withCompletion: { [weak self] _, _ in
            // Add localizedError to the 3DS2 SDK error
            let threeDSError = runtimeErrorEvent.nsErrorValue() as NSError
            var userInfo = threeDSError.userInfo
            userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

            let localizedError = NSError(
                domain: threeDSError.domain,
                code: threeDSError.code,
                userInfo: userInfo
            )

            self?.analyticsClient.log3DS2ChallengeFlowErrored(
                intentID: currentAction.intentStripeID,
                error: localizedError
            )
            currentAction.complete(
                with: STPPaymentHandlerActionStatus.failed,
                error: localizedError
            )
        })
    }

    /// :nodoc:
    @objc(transactionDidPresentChallengeScreen:)
    dynamic func transactionDidPresentChallengeScreen(_ transaction: STDSTransaction) {
        guard let currentAction else {
            stpAssertionFailure("Calling transactionDidPresentChallengeScreen without currentAction.")
            let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: ["error_message": "Calling transactionDidPresentChallengeScreen without currentAction."])
            analyticsClient.log(analytic: errorAnalytic, apiClient: apiClient)
            return
        }

        analyticsClient.log3DS2ChallengeFlowPresented(
            intentID: currentAction.intentStripeID,
            uiType: transaction.presentedChallengeUIType
        )
    }

    /// :nodoc:
    @objc(dismissChallengeViewController:forTransaction:)
    dynamic func dismiss(
        _ challengeViewController: UIViewController,
        for transaction: STDSTransaction
    ) {
        guard let currentAction else {
            stpAssertionFailure("Calling dismiss(challengeViewController:) without currentAction.")
            let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: ["error_message": "Calling dismiss(challengeViewController:) without currentAction."])
            analyticsClient.log(analytic: errorAnalytic, apiClient: apiClient)
            return
        }

        if let paymentSheet = currentAction.authenticationContext
            .authenticationPresentingViewController() as? PaymentSheetAuthenticationContext
        {
            paymentSheet.dismiss(challengeViewController, completion: nil)
        } else {
            challengeViewController.dismiss(animated: true, completion: nil)
        }
    }

    @_spi(STP) public func cancel3DS2ChallengeFlow() {
        guard let currentAction else {
            stpAssertionFailure("Calling cancel3DS2ChallengeFlow without currentAction.")
            let errorAnalytic = ErrorAnalytic(event: .unexpectedPaymentHandlerError, error: InternalError.invalidState, additionalNonPIIParams: ["error_message": "Calling cancel3DS2ChallengeFlow without currentAction."])
            analyticsClient.log(analytic: errorAnalytic, apiClient: apiClient)
            return
        }
        guard let transaction = currentAction.threeDS2Transaction else {
            stpAssertionFailure("Calling cancel3DS2ChallengeFlow without a threeDS2Transaction.")
            currentAction.complete(
                with: .failed,
                error: _error(for: .unexpectedErrorCode, loggingSafeErrorMessage: "Calling cancel3DS2ChallengeFlow without a threeDS2Transaction.")
            )
            return
        }
        transaction.cancelChallengeFlow()
    }
}

/// Internal authentication context for PaymentSheet magic
@_spi(STP) public protocol PaymentSheetAuthenticationContext: STPAuthenticationContext {
    func present(_ authenticationViewController: UIViewController, completion: @escaping () -> Void)
    func dismiss(_ authenticationViewController: UIViewController, completion: (() -> Void)?)
    func presentPollingVCForAction(action: STPPaymentHandlerPaymentIntentActionParams, type: STPPaymentMethodType, safariViewController: SFSafariViewController?)
}

// MARK: - Deprecated public funcs

extension STPPaymentHandler {
    @available(*, deprecated, renamed: "confirmPaymentIntent(params:authenticationContext:completion:)", message: "")
    @objc(confirmPayment:withAuthenticationContext:completion:)
    public func confirmPayment(
        _ paymentParams: STPPaymentIntentParams,
        with authenticationContext: STPAuthenticationContext,
        completion: @escaping STPPaymentHandlerActionPaymentIntentCompletionBlock
    ) {
        confirmPaymentIntent(params: paymentParams, authenticationContext: authenticationContext, completion: completion)
    }

    @available(*, deprecated, renamed: "confirmSetupIntent(params:authenticationContext:completion:)", message: "")
    @objc(confirmSetupIntent:withAuthenticationContext:completion:)
    public func confirmSetupIntent(
        _ setupIntentConfirmParams: STPSetupIntentConfirmParams,
        with authenticationContext: STPAuthenticationContext,
        completion: @escaping STPPaymentHandlerActionSetupIntentCompletionBlock
    ) {
        confirmSetupIntent(params: setupIntentConfirmParams, authenticationContext: authenticationContext, completion: completion)
    }

    @available(*, deprecated, renamed: "handleNextAction(paymentIntentClientSecret:authenticationContext:completion:)", message: "")
    @objc(handleNextActionForPayment:withAuthenticationContext:returnURL:completion:)
    public func handleNextAction(
        forPayment paymentIntentClientSecret: String,
        with authenticationContext: STPAuthenticationContext,
        returnURL: String?,
        completion: @escaping STPPaymentHandlerActionPaymentIntentCompletionBlock
    ) {
        handleNextAction(paymentIntentClientSecret: paymentIntentClientSecret, authenticationContext: authenticationContext, returnURL: returnURL, completion: completion)
    }

    @available(*, deprecated, renamed: "handleNextAction(setupIntentClientSecret:authenticationContext:completion:)", message: "")
    @objc(handleNextActionForSetupIntent:withAuthenticationContext:returnURL:completion:)
    public func handleNextAction(
        forSetupIntent setupIntentClientSecret: String,
        with authenticationContext: STPAuthenticationContext,
        returnURL: String?,
        completion: @escaping STPPaymentHandlerActionSetupIntentCompletionBlock
    ) {
        handleNextAction(setupIntentClientSecret: setupIntentClientSecret, authenticationContext: authenticationContext, returnURL: returnURL, completion: completion)
    }
}
